Security-Hardening Runde 5: Hack-Das-Ding (DSGVO-GAU + Timing + XSS)

Live-Pentest gegen Dev-Server + 3 parallele Audit-Agents.

🚨 CRITICAL: /api/uploads/* war ohne Auth erreichbar
- express.static('/api/uploads', ...) → jeder konnte mit ratbarer URL
  sensible PDFs (Kündigungsbestätigungen, Ausweise, Bankkarten,
  Vollmachten) ziehen. Live-verifiziert: 23-KB-PDF eines echten Kunden
  ohne Login geladen.
- Fix: authenticate-Middleware vor static-Handler (req.query.token
  unterstützung war schon da, jetzt aktiv genutzt).
- Frontend: utils/fileUrl.ts hängt JWT als ?token=... an. 24 direkte
  /api${...Path}-URLs in 5 Dateien per Skript migriert (CustomerDetail,
  ContractDetail, InvoicesSection, PdfTemplates, GDPRDashboard).

🚨 HIGH: Login-Timing User-Enumeration
- bcrypt.compare wurde nur bei existierenden Usern ausgeführt → 110ms
  vs 10ms Differenz, Email-Enumeration trivial messbar.
- Fix: Dummy-bcrypt-compare bei invalid user (Cost 12). Plus Lazy-
  Rehash bei erfolgreichem Login: alte Cost-10-Hashes (z.B. admin aus
  Installation) werden auf BCRYPT_COST upgraded, damit Dummy- und
  Echt-Hash-Cost zusammenpassen.
- Live-verifiziert nach Admin-Rehash: 422ms (invalid) vs 423ms (valid)
  – Side-Channel dicht.

🚨 HIGH: XSS via Privacy-Policy/Imprint-HTML
- 4 Frontend-Seiten renderten Backend-HTML ohne DOMPurify
  (PortalPrivacy, ConsentPage, PortalWebsitePrivacy, PortalImprint).
  Admin-eingegebene <script>-Tags wären bei jedem Portal-Kunden-
  Besuch ausgeführt worden – auch auf der öffentlichen Consent-Seite.
- Fix: DOMPurify.sanitize mit strikter FORBID_TAGS/ATTR Config.

🛡 HIGH: IDOR-Härtung an Upload-/Document-Endpoints
- canAccessContract jetzt in: uploadContractDocument,
  deleteContractDocument, handleContractDocumentUpload (Kündigungs-
  Letter+Confirmation), handleContractDocumentDelete,
  saveAttachmentAsContractDocument.
- Defense-in-Depth: aktuell durch requirePermission abgesichert,
  schützt auch gegen künftige Staff-Scoping-Rollen.

Offen für v1.1:
- Per-File-Ownership-Check für /api/uploads (Kontroll-Lookup
  welche Ressource zur Datei gehört)
- TipTap-Link-Tool javascript:-Protokoll blockieren
- Prisma-Error-Messages in Admin-Endpoints generisch sanitisieren

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:21:37 +02:00
parent dea2da0271
commit 35745ce3bb
16 changed files with 169 additions and 31 deletions
+12 -11
View File
@@ -20,6 +20,7 @@ import { formatDate } from '../../utils/dateFormat';
import { getContractTypeInfo } from '../../utils/contractInfo';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types';
import { fileUrl } from '../../utils/fileUrl';
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
const { id } = useParams();
@@ -564,7 +565,7 @@ function BusinessDataCard({
{customer.businessRegistrationPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/api${customer.businessRegistrationPath}`}
href={fileUrl(customer.businessRegistrationPath)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -573,7 +574,7 @@ function BusinessDataCard({
Anzeigen
</a>
<a
href={`/api${customer.businessRegistrationPath}`}
href={fileUrl(customer.businessRegistrationPath)}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -615,7 +616,7 @@ function BusinessDataCard({
{customer.commercialRegisterPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/api${customer.commercialRegisterPath}`}
href={fileUrl(customer.commercialRegisterPath)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -624,7 +625,7 @@ function BusinessDataCard({
Anzeigen
</a>
<a
href={`/api${customer.commercialRegisterPath}`}
href={fileUrl(customer.commercialRegisterPath)}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -935,7 +936,7 @@ function BankCardsTab({
{card.documentPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/api${card.documentPath}`}
href={fileUrl(card.documentPath)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -944,7 +945,7 @@ function BankCardsTab({
Anzeigen
</a>
<a
href={`/api${card.documentPath}`}
href={fileUrl(card.documentPath)}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -1171,7 +1172,7 @@ function DocumentsTab({
{doc.documentPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/api${doc.documentPath}`}
href={fileUrl(doc.documentPath)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -1180,7 +1181,7 @@ function DocumentsTab({
Anzeigen
</a>
<a
href={`/api${doc.documentPath}`}
href={fileUrl(doc.documentPath)}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -3925,7 +3926,7 @@ function ConsentTab({
{customer.privacyPolicyPath ? (
<div className="flex items-center gap-3 flex-wrap">
<a
href={`/api${customer.privacyPolicyPath}`}
href={fileUrl(customer.privacyPolicyPath)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -3934,7 +3935,7 @@ function ConsentTab({
Anzeigen
</a>
<a
href={`/api${customer.privacyPolicyPath}`}
href={fileUrl(customer.privacyPolicyPath)}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -4231,7 +4232,7 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
{auth.documentPath ? (
<>
<a
href={`/api${auth.documentPath}`}
href={fileUrl(auth.documentPath)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs flex items-center gap-1"