opencrm/docs/SECURITY-HARDENING.md

353 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 🛡️ 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 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/<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.
### 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
`<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).
- **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.