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:
duffyduck 2026-05-01 08:15:38 +02:00
parent d063d67282
commit 58af2dd255
3 changed files with 271 additions and 187 deletions

View File

@ -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 13: 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)

251
docs/SECURITY-HARDENING.md Normal file
View File

@ -0,0 +1,251 @@
# 🛡️ Security-Hardening die ganze Geschichte
Dokumentiert die acht Hardening-Runden, die OpenCRM zwischen erster
Code-Review und öffentlichem Deployment durchlaufen hat.
Format pro Runde: **Was war kaputt****Wie es gefixt wurde** → wo möglich
**Live-Test-Resultate**.
> Die ersten beiden Runden gibt es zusätzlich als ausführlicheren Review in
> [SECURITY-REVIEW.md](./SECURITY-REVIEW.md).
---
## 📊 Live-verifizierte Tests im Überblick
Die wichtigsten Schwachstellen wurden mit echten HTTP-Requests gegen den Dev-Server
durchgespielt statisches Code-Review fand ca. 70 % der Findings, die letzten 30 %
brauchten Live-Tests.
### Runde 4 IDOR an Customer-Sub-Resourcen (Live als Portal-Kunde)
| 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" |
### Runde 5 DSGVO-GAU + Timing-Side-Channel
| Test | Vorher | Nachher |
| ------------------------------------------------- | --------------------------------------- | ---------------------------------- |
| `/api/uploads/cancellation-confirmations/*.pdf` | 🚨 **HTTP 200 mit echtem Kunden-PDF** | ✅ 401 ohne Token |
| `/api/uploads/...?token=<jwt>` | n/a | ✅ 200 |
| Login `admin@admin.com` (falsches Passwort) | 110 ms | 423 ms |
| Login `not-existent@x.de` | 10 ms (verräterisch) | 422 ms (matcht admin) |
| Portal-Lieferbestätigung-Upload auf fremden Vertrag | (per-Permission abgewehrt) | ✅ 403 |
### Runde 6 Customer-Liste-Leak + XFF-Bypass
| Test | Vorher | Nachher |
| --------------------------------------------- | --------------------------------------- | ---------------------------------------- |
| `GET /api/customers` als Portal | 🚨 **alle Kunden mit Namen/E-Mail** | ✅ nur eigene + vertretene |
| 12× Login mit rotierendem `X-Forwarded-For` | 🚨 alle 401, kein 429 | ✅ XFF nur von Loopback akzeptiert |
| Self-Grant (`representativeId == customerId`) | 🚨 DB-Eintrag erstellt | ✅ 400 |
| Authorization für non-existent Customer 9999 | 🚨 Prisma-Stack mit Pfaden geleakt | ✅ 403 generisch |
| Customer-Existence via 404-vs-403 | 🟡 enumerierbar | ✅ alle 403 uniform |
| Listen-Adresse (Production) | `0.0.0.0` (extern erreichbar) | `127.0.0.1` (nur via Reverse-Proxy) |
### Runde 7 SSRF + Logout
| Test | Vorher | Nachher |
| ----------------------------------------------------------- | --------------------- | ---------------------------------------- |
| `test-connection` mit `apiUrl=http://169.254.169.254` | 8 s Timeout (SSRF) | ✅ 400 „geblockte Adresse" |
| `test-mail-access` mit `smtpServer=metadata.google.internal`| Connection-Versuch | ✅ 400 |
| `test-mail-access` mit `0.0.0.0` | Connection-Versuch | ✅ 400 |
| `test-mail-access` mit `127.0.0.1` (Plesk-Fall) | OK | ✅ OK (weiter erlaubt) |
| `POST /api/auth/logout` | 404 (Endpoint fehlte) | ✅ 200 |
| `GET /me` nach Logout | weiter 200 (bis 7 d) | ✅ 401 „Sitzung ungültig" |
### Runde 8 DNS-Rebinding + Per-File-Ownership
| Test | Resultat |
| ----------------------------------------------------- | --------------------------------------------- |
| Admin lädt eigene Datei | ✅ HTTP 200, PDF |
| Portal lädt eigene Contract-Datei | ✅ HTTP 200, PDF |
| Portal lädt random Pfad ohne DB-Resource | ✅ HTTP 404 |
| Path-Traversal `..` im Pfad | ✅ HTTP 400 |
| URL-encoded Traversal `%2F..%2F` | ✅ HTTP 400 |
| Ohne Token | ✅ HTTP 401 |
| Backwards-Compat `/api/uploads/<path>` | ✅ HTTP 200 (intern derselbe Owner-Check) |
| Legitimer Hostname (gmail.com) | ✅ DNS-Resolve OK, normaler SMTP-Auth-Fail |
| Hostname mit interner Target-IP | ✅ HTTP 400 geblockt |
---
## 🗂️ Runde-für-Runde
### Runde 1 Erste kritische Findings (statisches Review)
- CORS komplett offen → `CORS_ORIGINS` explizit
- Keine Security-Headers → Helmet aktiviert (HSTS, X-Frame-Options, nosniff …)
- JWT-Fallback-Secret entfernt → Fail-Fast beim Start (≥ 32 Zeichen JWT_SECRET, 64-Hex ENCRYPTION_KEY)
- IDOR bei 7 Contract-Endpoints (`canAccessContract`)
- XSS via Email-Body → DOMPurify mit strikter Config
- Customer-API: Passwort-Hashes in API-Responses → Sanitizer
- Portal-JWT-Invalidation nach Passwort-Reset (`portalTokenInvalidatedAt`)
- Body-Size-Limit 5 MB
### Runde 2 Deep-Dive (parallele Audit-Agents)
- **Zip-Slip im Backup-Upload** (Arbitrary File Write) → Pfad-Validation
- **Mass Assignment bei Customer/User** (Privilege Escalation via `roleIds`!) → Whitelist-Picker
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
- Path-Traversal bei Backup-Name und GDPR-Proof-Download → Regex/Safelist
### Runde 3 Tiefer Dive (8 weitere Hardenings)
- JWT algorithm confusion: `jwt.verify(..., { algorithms: ['HS256'] })`
- `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy
- IDOR Invoice (`/api/energy-details/:ecdId/invoices`) → `canAccessEnergyContractDetails`
- IDOR PDF-Template-Generator → `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`
- bcrypt cost 10 → 12 (OWASP 2026)
### Runde 4 Live-Tests gegen Dev-Server (Tabelle oben)
`getCustomer`, alle Customer-Sub-Resources (addresses/bank-cards/…) und die
GDPR-Endpoints hatten nur Daten-Sanitizer, aber keinen `canAccessCustomer`-Check.
Portal-Kunde konnte live `GET /api/customers/<fremde-id>` machen → **9 IDORs**.
Plus Error-Handler: `err.status` wird respektiert (413/400 statt pauschalem 500).
### Runde 5 Hack-Das-Ding-Audit
- 🚨 **`/api/uploads/*` ohne Auth** (DSGVO-GAU) → `authenticate`-Middleware,
Frontend-Helper `fileUrl(path)` hängt Token an, 24 URLs migriert.
- **Login-Timing-Side-Channel**: 110 ms vs 10 ms → Dummy-bcrypt-compare
(Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login.
- **XSS via Privacy Policy / Imprint** in 4 Frontend-Seiten → DOMPurify.
- IDOR-Härtung an 5 weiteren Upload/Delete/Email-Save-Stellen
(`canAccessContract`).
### Runde 6 Tiefer Live-Pentest (Tabelle oben)
- 🚨 **`GET /api/customers` Customer-Liste-Leak** → Portal-Filter
- 🚨 **Rate-Limit-Bypass via X-Forwarded-For**`trust proxy = 'loopback'`
+ `LISTEN_ADDR=127.0.0.1` in Production
- Self-Grant + Existence-Disclosure in `toggleMyAuthorization` → Self-Grant
400, Existenz + aktive `CustomerRepresentative`-Beziehung in einem Query,
beide Fehlfälle uniform 403.
- Prisma-Error-Leaks generisch ersetzt.
### Runde 7 Letzter Schliff
- **SSRF-Schutz** in `test-connection` und `test-mail-access`
`utils/ssrfGuard.ts` blockiert 169.254.0.0/16, 0.0.0.0/8,
Multicast/Reserved-Ranges, AWS-IPv6-Metadata, IPv6-Link-Local und
Cloud-Metadata-Hostnames. Loopback bleibt erlaubt für Plesk/Postfix.
- **Logout-Endpoint** `POST /api/auth/logout` setzt `tokenInvalidatedAt`
/ `portalTokenInvalidatedAt` auf jetzt.
### Runde 8 Loose Ends
- **DNS-Rebinding-Schutz**: `safeResolveHost()` löst Hostnames vor Connect
zu IPs auf, prüft jede gegen die Block-Liste, gibt `{ ip, servername }`
zurück. Connection läuft gegen IP, der Hostname als TLS-SNI ein
zweiter DNS-Lookup kann keine geblockte IP unterschieben.
- **Per-File-Ownership-Check**: `app.use('/api/uploads', authenticate,
express.static)` ersetzt durch `GET /api/files/download?path=...` mit
DB-Lookup (`fileDownload.service.ts`). 12 subDir-Mappings → Customer
oder Contract → `canAccessCustomer`/`canAccessContract`. Backwards-
Compat-Shim für `/api/uploads/*` ruft denselben Owner-Check.
---
## 🔧 Geprüft + sauber (kein Bug, aber explizit getestet)
- Prototype Pollution beim Login (Body mit `__proto__` → kein Effekt)
- HTTP-Method-Override-Header (X-HTTP-Method-Override: DELETE → ignoriert)
- Path-Traversal in Backup-Name (Regex blockiert)
- Developer-Routes existieren nicht (`/api/developer/*` → 404)
- Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403
- Self-grant Vollmacht via `customers/X/representatives` → 403
- `/api/customers/:id` GET liefert 403 für fremde, kein 404-Existence-Leak
- Public Consent Endpoint: 122-bit Random-UUID, nicht brute-force-bar
- 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 / Email-Config / Backup-Endpoints als Portal: 403
- Query-Filter-Override (`?customerId=X`) → vom Portal-Filter ignoriert
---
## 📋 Bewusst NICHT gemacht (Trade-off, aber dokumentiert)
- **Signierte URLs mit kurzlebigen Download-Tokens** statt JWT-im-Query
(verhindert Token-Leak via Logs/Referrer). Nicht trivial wegen
`<a href>`-Downloads ohne JS v1.2-Item.
- **`/api/contracts/:id` GET liefert 404 für nicht-existente IDs**
(Existence-Probing). Vereinheitlichung auf 403 wäre sauberer; da
Contract-IDs aber nicht direkt mit personenbezogenen Daten korrelieren,
niedrig-Prio.
- **Prisma-Error-Leaks in anderen Admin-Endpoints** (z. B. `addInvoice`
bei Validation-Fehler) Defense-in-Depth-Kandidat, aber nur Admin-
erreichbar.
- **TipTap-Link-Tool**: `javascript:`-Protokoll blockieren (Admin-only
erreichbar, niedrig-Prio).
---
## 🚀 Production-Deployment-Checkliste
Vor dem öffentlichen Schalten muss in der Production-`.env`:
- `JWT_SECRET` rotieren: `openssl rand -hex 64`
- `ENCRYPTION_KEY` rotieren: `openssl rand -hex 32` (genau 64 Hex-Zeichen)
- `NODE_ENV=production`
- `CORS_ORIGINS=https://deine-domain.de` (oder leer für Same-Origin)
- `LISTEN_ADDR=127.0.0.1` (nur lokaler Reverse-Proxy darf connecten)
- Reverse-Proxy (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For`
hart auf die echte Client-IP gesetzt wird (nicht angefügt) sonst
Rate-Limit-Bypass möglich.
- Manuelle Test-Checkliste aus [TESTING.md](./TESTING.md) einmal komplett
durchklicken.
---
## 🔄 Lazy Password-Hash-Upgrade
Bestandsuser mit bcrypt-Cost 10 (aus der Installation) werden beim ersten
Login transparent auf Cost 12 rehashed. Damit gleicht sich die
Antwortzeit beim Login automatisch der Dummy-bcrypt-Zeit (Cost 12) an
Login-Timing-Side-Channels schließen sich von alleine im Lauf der ersten
Wochen nach Deployment.
---
## 🗨️ Lehre aus der Session
Statische Audit-Agents finden ca. 70 % der Findings, die letzten ~30 %
brauchten Live-Tests gegen den laufenden Server. Sie kennen den exakten
Permission-State der DB nicht (raten z. B., dass `gdpr:export` Portal-
User-zugänglich sei war's nicht), übersehen aber, dass ein
Daten-Sanitizer einen Permission-Check vortäuschen kann (Runde 4 / 6).
**Take-away:** „Code sieht sicher aus" ≠ „Server verhält sich sicher".
Vor jedem Launch mit echten Tokens probieren.
---
## 📑 Commit-Historie
| Commit | Runde | Hauptthema |
| --------- | ------- | -------------------------------------------------------------- |
| (mehrere) | 1 + 2 | Erste Review-Welle, dokumentiert in SECURITY-REVIEW.md |
| (mehrere) | 3 | JWT alg, trust-proxy, Invoice/PDF IDOR, Attachment, Provider, SMTP-CRLF, bcrypt |
| `334c408` | 4 | 9 Live-IDORs (customer.* + gdpr.*) + Error-Handler |
| `8be9bae` | 5 | Uploads-Auth + Login-Timing + XSS |
| `4e91d96` | 6 | Customer-List-Leak + XFF-Bypass + Auth-Toggle |
| `12b9abe` | 7 | SSRF-Schutz + Logout |
| `d063d67` | 8 | DNS-Rebinding + Per-File-Ownership |

View File

@ -1,5 +1,9 @@
# Security-Review vor 1.0.0
> 📌 **Diese Datei dokumentiert nur die ersten 2 Runden ausführlich.**
> Die vollständige Hardening-Story über alle **8 Runden** inkl. Live-Test-
> Tabellen findest du in **[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**.
> **Version 2** dieser Review wurde in 2 Runden durchgeführt.
> Runde 1: erste kritische Findings (CORS, Helmet, JWT-Fallback, grobes IDOR, XSS, Data Exposure).
> Runde 2 (weiter unten): **Deep-Dive** mit parallelen Audit-Agents fand weitere IDOR-Stellen, Mass Assignment, Zip-Slip, Path-Traversal.