opencrm/docs/SECURITY-HARDENING.md

18 KiB
Raw Blame History

🛡️ 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 kaputtWie 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 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-Fortrust 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-accessutils/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 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 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
(folgt) 9 npm audit fix + Audit-Chain-Rehash + Doku

🧭 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.