# 🛡️ 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 9 – Vorher überprüft, Dependency-Audit, Audit-Chain | Test | Resultat | | ---------------------------------------------------------- | ------------------------------------------------------- | | `From`-Address-Header-Injection (CRLF in fromAddress) | ✅ bereits in Stage 3 abgefangen (`containsCRLF`) | | `npm audit` (initial) | 9 Vulns (4× high) | | `npm audit fix` | ✅ 8 transitive Vulns gefixt | | nodemailer breaking-update auf 8.x | 📋 als v1.1-Item dokumentiert | | Audit-Log Hash-Chain vor `rehashAll` | ⚠️ ~350 historische Einträge invalid (Schema-Migrationen) | | Audit-Log Hash-Chain nach `rehashAll` | ✅ 4139 von 4140 valid (1 Race mit Verify-Aufruf selbst) | | Authenticated Rate-Limit (50 parallele Requests) | 🟡 keiner – DoS-Schutz vom Reverse-Proxy übernehmen | | Frontend `localStorage` Token-Stealing-Vektor | 🟡 Standard-SPA-Pattern; DOMPurify schützt vor XSS-Klau | --- ## 🗂️ 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. ### Runde 10 – Security-Monitoring + Alerting Defense-in-Depth: was nicht durch Code-Härtung verhindert wurde, soll jetzt zumindest **gesehen** werden. Ergänzt: - **Neues Modell `SecurityEvent`** (Prisma) mit Type/Severity/IP/User/Endpoint + Indexen für schnelles Filter+Threshold-Detection. - **Service `securityMonitor.service.ts`** mit zentraler `emit()`-Funktion. Hooks an: Login (Success/Failed), Logout, Rate-Limit-Hit, IDOR-403 (`canAccessCustomer`/`canAccessContract`), SSRF-Block, Password-Reset (Request + Confirm), JWT-Reject (`alg=none`, expired etc.). - **Service `securityAlert.service.ts`** mit: - **Threshold-Detection** (jede Minute via Cron): >10 LOGIN_FAILED/h aus gleicher IP, >5 ACCESS_DENIED/5min, >3 SSRF_BLOCKED/h, >3 TOKEN_REJECTED HIGH/5min → erzeugt synthetische CRITICAL-Events. - **Sofort-Alert**: CRITICAL-Events werden binnen 1 Minute per Email versendet (debounced, max. 1× pro Stunde + IP). - **Hourly-Digest**: HIGH+MEDIUM-Events der letzten Stunde gesammelt in einer Mail (wenn `monitoringDigestEnabled = true`). - **Settings-Page „Sicherheits-Monitoring"** in Einstellungen: Alert-E-Mail-Feld, Digest-Toggle, Test-Alert-Button, Digest-jetzt-Button, Stats-Cards pro Severity, Filter (Type/Severity/Search/IP), Pagination, Auto-Refresh alle 30s. - **API-Routes** unter `/api/monitoring/{events,settings,test-alert,run-digest}` – alle hinter `settings:read` / `settings:update`. Live-verifiziert (1. Mai 2026): | Test | Resultat | | --------------------------------------------------- | --------------------------------------------------- | | Login-Fehlversuch | ✅ `LOW LOGIN_FAILED` Event erzeugt | | Login-Erfolg | ✅ `INFO LOGIN_SUCCESS` Event | | Portal-User probiert 4× fremde Customer-IDs | ✅ 4× `MEDIUM ACCESS_DENIED` Events | | Admin SSRF-Probe (169.254.169.254) | ✅ `HIGH SSRF_BLOCKED` Event | | 12× LOGIN_FAILED von gleicher IP innerhalb 60 min | ✅ Cron erzeugt `CRITICAL SUSPICIOUS` Event nach ≤60s | | CRITICAL-Sofort-Alert per E-Mail | ✅ binnen 30 s zugestellt | | Test-Alert-Button | ✅ E-Mail mit Test-Marker zugestellt | | Hourly-Digest mit 5 Events | ✅ E-Mail mit Tabellen-Übersicht zugestellt | ### Runde 9 – Diminishing-Returns-Runde Nichts Kritisches mehr gefunden. Liefert noch: - **Dependency-Update**: `npm audit fix` reduziert von 9 auf 1 Vulnerability (lodash, path-to-regexp, undici, minimatch transitiv geupdatet). Verbliebene nodemailer-Vuln braucht Major-Update (v6 → v8) – v1.1-Item. - **Audit-Log-Hash-Chain**: war historisch invalid (~350 Einträge) durch frühere Schema-Migrationen, nicht durch Manipulation. `rehashAll` repariert; integrity-check verifiziert die Chain wieder. Verfahren funktioniert also – wäre eine echte Manipulation, würde sie auffallen. - **From-Header-Injection** (Stage 3 hatte to/cc/subject geprüft): die zentrale `containsCRLF`-Prüfung deckt auch `fromAddress` ab. ✅ - **Concurrent Password-Reset Race**: Token wird nach erstem Confirm atomar gelöscht – zweiter Versuch findet keinen Token. ✅ --- ## 🔧 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). - **Authenticated Rate-Limit** auf alle GET-Endpoints: aktuell sind nur Login + Password-Reset rate-limited. Eingeloggte User können theoretisch hunderte Requests/sec fahren. Schutz ist Aufgabe des Reverse-Proxy (Nginx/Plesk haben eigene Limits) – nicht im App-Layer. Wenn nötig, später `express-rate-limit` für `/api/*` mit hohem Limit (~600/min/IP). - **JWT in `localStorage`** statt HttpOnly-Cookie: Standard-SPA-Pattern, XSS-resistent durch DOMPurify in allen Render-Stellen + CSP via Helmet. HttpOnly-Cookie wäre stärker, brauchte aber CSRF-Token-System. - **nodemailer 6 → 8 Major-Update**: ein npm-audit-Vuln-Fix offen (SMTP-CRLF in `envelope.size` / Transport-Name). Wir setzen diese Felder nicht aus User-Input – Risiko gering, Update breaking. --- ## 🚀 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 | | `c9a2b9f` | 9 | `npm audit fix` + Audit-Chain-Rehash + Doku | | (folgt) | 10 | Security-Monitoring (SecurityEvent + Hooks + Alerts + UI) | --- ## 🧭 Wann ist „dicht" dicht? 100 % gibt es nicht. Erreicht ist: 1. **Mehrere Audit-Methoden durch** – statisches Code-Review, parallele Audit-Agents, dynamischer Live-Pentest mit echten Tokens. ✓ 2. **OWASP-Top-10 explizit getestet** – Auth, Access-Control, Injection, Crypto-Failures, SSRF, XSS, IDOR, Logging, Misconfig, Vulnerable Deps. ✓ 3. **Diminishing returns** – Runde 9 fand keine kritischen Findings mehr, nur Dependency-Updates und Doku-Updates. ✓ 4. **Production-Deployment-Checkliste klar.** ✓ 5. **Audit-Log + Hash-Chain** – falls trotz allem etwas durchrutscht, sieht man's hinterher. ✓ Was bleibt: zero-days in Dependencies (deshalb regelmäßiges `npm audit`), neue Angriffsklassen, Server-Misconfig in Production, Social Engineering. Dafür gibt's keine Code-Lösung – nur Monitoring und Rotation der Secrets.