15 KiB
🛡️ 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.
📊 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_ORIGINSexplizit - 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 = 1fü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-HelperfileUrl(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/customersCustomer-Liste-Leak → Portal-Filter - 🚨 Rate-Limit-Bypass via X-Forwarded-For →
trust proxy = 'loopback'LISTEN_ADDR=127.0.0.1in Production
- Self-Grant + Existence-Disclosure in
toggleMyAuthorization→ Self-Grant 400, Existenz + aktiveCustomerRepresentative-Beziehung in einem Query, beide Fehlfälle uniform 403. - Prisma-Error-Leaks generisch ersetzt.
Runde 7 – Letzter Schliff
- SSRF-Schutz in
test-connectionundtest-mail-access→utils/ssrfGuard.tsblockiert 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/logoutsetzttokenInvalidatedAt/portalTokenInvalidatedAtauf 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 durchGET /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/:idGET 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/:idGET 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.
addInvoicebei 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_SECRETrotieren:openssl rand -hex 64ENCRYPTION_KEYrotieren:openssl rand -hex 32(genau 64 Hex-Zeichen)NODE_ENV=productionCORS_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-Forhart auf die echte Client-IP gesetzt wird (nicht angefügt) – sonst Rate-Limit-Bypass möglich. - Manuelle Test-Checkliste aus 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 |