# 🛡️ 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=` | 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/` | ✅ 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/` 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 ``-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 |