docs: Security-Hardening in eigene MD ausgelagert + Live-Tabellen
- 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) <noreply@anthropic.com>
This commit is contained in:
+16
-187
@@ -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
|
||||
<a href>-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 <a href>-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
|
||||
`<script>`-Tags wären bei jedem Portal-Kunden-Besuch ausgeführt worden.
|
||||
Jetzt mit strikter Sanitize-Config (FORBID_TAGS/ATTR).
|
||||
- **IDOR-Härtung Upload/Delete/SaveAttachment**: `canAccessContract` jetzt
|
||||
in `uploadContractDocument`, `deleteContractDocument`, im generischen
|
||||
`handleContractDocumentUpload` (Kündigungsschreiben + -bestätigungen)
|
||||
und in `saveAttachmentAsContractDocument`. Defense-in-Depth, blockt
|
||||
auch bei künftigen Staff-Scoping-Rollen.
|
||||
- Global Error-Handler: `err.status` wird respektiert (413/400 statt 500).
|
||||
|
||||
**Offen für v1.1**:
|
||||
- Per-File-Ownership-Check bei `/api/uploads/*` (aktuell reicht
|
||||
Authentifizierung, kein Datei-spezifischer Owner-Check). Implementierung
|
||||
bräuchte dedizierten `GET /api/files/download?path=...`-Endpoint mit
|
||||
DB-Lookup, welche Ressource zur Datei gehört.
|
||||
- TipTap-Link-Tool: `javascript:`-Protokoll blockieren (Admin-only erreichbar,
|
||||
niedrig-Prio).
|
||||
|
||||
- **Runde 4 – Live-Tests gegen Dev-Server deckten 9 weitere IDORs auf:**
|
||||
- `getCustomer` + `getAddresses`/`getBankCards`/`getDocuments`/`getMeters`/`getRepresentatives`/`getPortalSettings` hatten NUR Daten-Sanitizer aber KEINEN `canAccessCustomer`-Check
|
||||
- `gdpr.getCustomerConsents` + `getAuthorizations` + `checkConsentStatus` ebenso ungeschützt
|
||||
- Portal-Kunde konnte live per `GET /api/customers/<fremde-id>` kompletten Fremdkunden-Datensatz auslesen → jetzt 403
|
||||
- Error-Handler: `err.status` wird jetzt respektiert (413/400 statt pauschalem 500)
|
||||
|
||||
**Live-verifiziert als Portal-Kunde gegen fremden Test-Kunden #4:**
|
||||
|
||||
| Endpoint | Vorher | Nachher |
|
||||
| -------------------------------------------- | ------------------------------- | ---------------------------- |
|
||||
| `GET /api/customers/4` | 🚨 **200 mit Daten** | ✅ 403 |
|
||||
| `GET /api/customers/4/addresses` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/bank-cards` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/documents` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/meters` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/representatives` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consents` | 🚨 200 mit Consent-Daten | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/authorizations` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consent-status` | 🚨 200 | ✅ 403 |
|
||||
| Eigene Daten `/api/customers/1` | ✅ 200 | ✅ 200 (unverändert) |
|
||||
| 12 MB Body | 500 „Interner Serverfehler" | ✅ 413 „Anfrage zu groß" |
|
||||
| Malformed JSON | 500 „Interner Serverfehler" | ✅ 400 „Ungültiges JSON" |
|
||||
|
||||
- Deployment-Checkliste komplett
|
||||
- [x] **🛡️ Security-Hardening vor Production-Deployment (8 Runden)**
|
||||
- Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs:
|
||||
**[docs/SECURITY-HARDENING.md](../docs/SECURITY-HARDENING.md)**
|
||||
- Erste 2 Runden zusätzlich ausführlich in
|
||||
[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)
|
||||
- Highlights:
|
||||
- Runde 1–3: CORS, Helmet, JWT-Fallback, IDOR-Welle 1, XSS, Mass
|
||||
Assignment, Zip-Slip, Path-Traversal, JWT-Algorithm, Rate-Limiter
|
||||
- Runde 4: 9 Live-IDORs (customer.\*/gdpr.\*) + Error-Handler
|
||||
- Runde 5: `/api/uploads`-Auth (DSGVO-GAU), Login-Timing,
|
||||
Privacy-Policy-XSS
|
||||
- Runde 6: Customer-List-Leak, XFF-Rate-Limit-Bypass,
|
||||
Self-Grant + Existence-Disclosure
|
||||
- Runde 7: SSRF-Schutz (Cloud-Metadata-Block), Logout-Endpoint
|
||||
- Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
|
||||
- Deployment-Checkliste komplett (in HARDENING.md)
|
||||
|
||||
- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**
|
||||
- **Passwort vergessen-Flow** (Login → "Passwort vergessen?" Link)
|
||||
|
||||
Reference in New Issue
Block a user