From 58af2dd25517ba9dbba5f01b138539288ccbc0a5 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 1 May 2026 08:15:38 +0200 Subject: [PATCH] docs: Security-Hardening in eigene MD ausgelagert + Live-Tabellen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue docs/SECURITY-HARDENING.md mit der ganzen 8-Runden-Story inkl. aller Live-Test-Tabellen (Runden 4–8 jeweils mit Vorher/Nachher), geprüft+sauber-Liste, Trade-offs und Deployment-Checkliste. - backend/todo.md: kompletter Hardening-Block raus, ersetzt durch knappen Verweis (250 statt 421 Zeilen). todo.md ist jetzt wieder echte Todo-Liste, nicht Security-Doku. - docs/SECURITY-REVIEW.md: Banner oben, der auf HARDENING.md verweist (REVIEW.md bleibt als ausführliche Doku der ersten 2 Runden). Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/todo.md | 203 +++--------------------------- docs/SECURITY-HARDENING.md | 251 +++++++++++++++++++++++++++++++++++++ docs/SECURITY-REVIEW.md | 4 + 3 files changed, 271 insertions(+), 187 deletions(-) create mode 100644 docs/SECURITY-HARDENING.md diff --git a/backend/todo.md b/backend/todo.md index f756910d..e10e178f 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -116,193 +116,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung `cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt" abzubilden. `ACTIVE` bleibt bis zur Bestätigung. -- [x] **🛡️ Security-Review + Hardening vor Production-Deployment (3 Runden)** - - Vollständiger Review aller kritischen Bereiche, dokumentiert in **[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)** - - **Runde 1 – 6 kritische + 2 wichtige Findings gefixt:** - - CORS offen → `CORS_ORIGINS` explizit - - Helmet + Security-Headers - - JWT-Fallback-Secret entfernt (Fail-Fast beim Start) - - IDOR bei 7 Contract-Endpoints - - XSS via Email-Body (DOMPurify) - - Customer-API Data Exposure (Passwort-Hashes) - - Portal-JWT-Invalidation nach Passwort-Reset - - Body-Size-Limit 5 MB - - **Runde 2 – Deep-Dive mit parallelen Audit-Agents, 5 weitere kritische + 2 wichtige:** - - Zip-Slip im Backup-Upload (Arbitrary File Write!) - - Mass Assignment bei Customer/User (Privilege Escalation via `roleIds`!) - - 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …) - - Path-Traversal bei Backup-Name und GDPR-Proof-Download - - **Runde 3 – Tiefer Dive (8 weitere Hardenings):** - - JWT algorithm confusion: `jwt.verify` auf `algorithms: ['HS256']` festgenagelt - - `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy (sonst unwirksam) - - IDOR Invoice (alte `/api/energy-details/:ecdId/invoices`): jetzt `canAccessEnergyContractDetails` → Contract → customerId - - IDOR PDF-Template-Generator (`:id/generate/:contractId`): jetzt `canAccessContract` - - Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing - - Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr) - - SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller) - - bcrypt cost 10 → 12 (OWASP 2026) - - **Runde 8 – Loose Ends (DNS-Rebinding + Per-File-Ownership):** - - **DNS-Rebinding-Schutz** in test-connection / test-mail-access: - Hostnames werden vor Connect via `dns.resolve4/6` aufgelöst und - jede IP gegen die SSRF-Block-Liste geprüft. Connection läuft - anschließend gegen die IP, der ursprüngliche Hostname als - `tls.servername` für SNI/Cert-Validation. Ein zweiter DNS-Lookup - kann keine geblockte IP unterschieben. - - **Per-File-Ownership-Check** statt freiem static-Handler: - `app.use('/api/uploads', authenticate, express.static)` wird - ersetzt durch `GET /api/files/download?path=...`. Der - Controller mappt den Pfad via DB-Lookup auf Customer/Contract - und delegiert an `canAccessCustomer`/`canAccessContract` – - ein eingeloggter Portal-Kunde kann jetzt nur seine eigenen - (oder vertretene mit Vollmacht) Dateien laden, selbst wenn - er fremde Filenames irgendwo mitgeschnitten hätte. - `/api/uploads/*` bleibt als Backwards-Compat-Shim erhalten, - ruft aber denselben Owner-Check. - - 12 subDir-Mappings: bank-cards, documents, business-/commercial-/ - privacy-, authorizations, contract-documents, invoices, alle - 4 cancellation-* + pdf-templates (admin-only). - - Frontend `fileUrl()` zeigt jetzt auf den neuen Endpoint. - Path-Traversal wird sowohl per Format-Validation (begin /uploads/, - no '..') als auch durch absoluten Path-Vergleich gegen uploadsRoot - geblockt. - - Live-verifiziert: Portal-User lädt eigene Contract-Datei (200), - random Pfad (404), Traversal (400), kein Token (401), Backwards- - Compat-Shim (200). - - **Bewusst NICHT gemacht (für v1.2):** - - Signierte URLs mit kurzlebigen Download-Tokens statt JWT-im-Query - (verhindert Token-Leak via Logs/Referrer). Nicht trivial wegen - -Downloads ohne JS, lassen wir bis später. - - - **Runde 7 – Letzter Schliff (SSRF + Logout):** - - **SSRF-Schutz** in `test-connection` und `test-mail-access`: ein - Admin-User konnte über die Plesk-API-URL bzw. SMTP/IMAP-Server-Felder - Connections zu beliebigen IPs auslösen (Cloud-Metadata-Endpoints, - Link-Local, AWS/GCP-Metadata-Hosts). Internal-Port-Scanning via - Timing-Differenzen war messbar (22/80/3306/5432/6379 unterschiedlich). - Fix: neuer Helper `utils/ssrfGuard.ts` blockiert vor jeder ausgehenden - Verbindung 169.254.0.0/16, 0.0.0.0/8, Multicast/Reserved-Ranges, - AWS-IPv6-Metadata, IPv6-Link-Local und bekannte Cloud-Metadata- - Hostnames (metadata.google.internal etc.). Loopback (127.0.0.0/8) - bleibt erlaubt für legitime Plesk/Postfix-Setups. - - **Logout-Endpoint** `POST /api/auth/logout`: setzt - `tokenInvalidatedAt` / `portalTokenInvalidatedAt` auf jetzt. Auth- - Middleware prüft das Feld und lehnt Tokens mit `iat` davor ab. - JWTs sind stateless – ohne diesen Mechanismus bleibt ein - „abgemeldeter" Token bis zum natürlichen Expiry (7d) gültig. - - Live-verifiziert: 169.254.169.254/metadata.google.internal/0.0.0.0 - werden mit 400 abgelehnt; 127.0.0.1 weiter erlaubt; Logout - invalidiert den Token sofort (HTTP 401 „Sitzung ungültig"). - - **Geprüft + sauber (Runde 7):** - - Public Consent (random Hash → 404, kein Brute-Force durch 122-bit-UUID) - - Magic-Bytes-Bypass beim Upload (HTML als image/png) → blockiert - - PDF-Generation mit injizierten manualValues → kein XSS-Vektor (PDFs sind keine Web-Renderer) - - Audit-Logs für Portal-User: 403 - - Email-Config-Update als Portal: 403 - - Backup-Endpoints als Portal: 403 - - Query-Filter-Override (?customerId=X) → vom Portal-Filter ignoriert - - **Bewusst NICHT gefixt (zu invasiv für v1.0):** - - Vollständige DNS-Resolution beim SSRF-Guard (gegen DNS-Rebinding) – - kann legitimes CDN/Caching brechen, v1.1-Item. - - Per-File-Ownership-Check bei `/api/uploads` (siehe Runde 5). - - - **Runde 6 – Tiefer Live-Pentest (auf Wunsch des Users, „bevor andere es tun"):** - - 🚨 **`GET /api/customers` leakte als Portal-User die komplette - Kundendatenbank** (alle Namen, E-Mails, customerNumber etc.). Der - Single-Endpoint war Stage 4 mit `canAccessCustomer` gefixt, der List- - Endpoint nicht. Jetzt: Portal-User bekommt nur eigene + vertretene - Kunden (Filter im Controller). - - 🚨 **Rate-Limit-Bypass via `X-Forwarded-For`**: 12+ Login-Versuche - mit rotierenden XFF-Werten gingen alle durch ohne 429. `trust proxy = 1` - hat naiv jedem XFF-Wert vertraut. Jetzt: `trust proxy = 'loopback'` – - XFF wird nur akzeptiert wenn die Connection von 127.0.0.1 / ::1 kommt - (= lokaler Reverse-Proxy). Plus: `LISTEN_ADDR=127.0.0.1` in Production- - Default, damit das Backend nicht direkt von außen ansprechbar ist. - - **Self-Grant + Existence-Disclosure in `toggleMyAuthorization`**: - - Portal-User konnte sich selbst Vollmacht erteilen (1→1) und - Datensätze für beliebige `representativeId`s anlegen (auch nicht- - existierende, scheiterte erst auf DB-Constraint mit Prisma-Stack-Leak). - - 404 vs 403 erlaubte Existence-Probing der gesamten customer-ID-Range. - - Fix: Self-Grant 400er. Existenz + aktives `CustomerRepresentative`- - Verhältnis in einem Query, beide Fehlfälle identisch 403. - - **Prisma-Error-Leak generisch in `toggleMyAuthorization`**: keine - Prisma-Stacks mehr im Response. - - Live-verifiziert: Customer-Liste 3 statt 3000 (jetzt nur erlaubte), - Self-Grant 400, Existence-Disclosure dicht (alle 403 uniform), Auth - auf `/api/customers/:id` 200/403 (kein 404-Leak). - - **Geprüft + sauber (Runde 6):** - - Prototype Pollution beim Login → kein Effekt - - HTTP-Method-Override via Header → ignoriert - - Path-Traversal in Backup-Name → durch Regex blockiert - - Developer-Routes existieren nicht (404) - - Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403 - - Self-grant Vollmacht via `customers/X/representatives` → 403 (perm) - - `/api/customers/:id` GET: 200 für eigene, 403 sonst (kein 404-Leak) - - **Offen für v1.1:** - - `/api/contracts/:id` GET liefert 404 für nicht-existente IDs (Existence- - Probing). Da contractIds aber nicht direkt mit personenbezogenen Daten - korrelieren, niedrig-Prio. Vereinheitlichung auf 403 wäre sauberer. - - Prisma-Error-Leaks in anderen Admin-Endpoints (z.B. `addInvoice` bei - Validation-Fehler) – Defense-in-Depth-Kandidat. - - - **Runde 5 – Hack-Das-Ding-Audit (Live-Pentest + 3 parallele Audit-Agents):** - - 🚨 **`/api/uploads/*` war OHNE AUTH erreichbar** (DSGVO-GAU!) – jetzt hinter - `authenticate`. Direkte -Links nutzen `?token=...` Query-Parameter, - unterstützt von auth-Middleware. Frontend-Helper `fileUrl(path)` hängt - Token automatisch an, 24 URLs migriert (CustomerDetail, ContractDetail, - InvoicesSection, PdfTemplates, GDPRDashboard). - - **Login-Timing-Side-Channel**: Bei ungültigem User fehlte `bcrypt.compare` - → 110ms vs 10ms, User-Enumeration trivial. Jetzt Dummy-bcrypt-compare - (Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login. - Live-verifiziert: 422ms vs 425ms – Timing-Angriff dicht. - - **XSS via Privacy Policy / Imprint**: 4 Frontend-Seiten renderten - Backend-HTML ohne DOMPurify (`PortalPrivacy`, `ConsentPage`, - `PortalWebsitePrivacy`, `PortalImprint`). Admin-eingegebene - `