docs: Pentest-Runden 11 + 12 in SECURITY-HARDENING + README aktualisieren

SECURITY-HARDENING.md:
- Runde 11 "Externer Pentest-Folge: Header-Hygiene + Klartext-Audit":
  HSTS-Doppel-Header weg, Cache-Control je nach Pfad differenziert,
  CSP No-Fallback-Direktiven + frame-ancestors auf 'self', BREACH-
  Mitigation via gzip off im Reverse-Proxy für /api/*, Server-/
  X-Served-By-Banner entfernt, Audit-Log für die 6 Klartext-Passwort-
  Read-Endpoints (CRITICAL).
- Runde 12 "JWT raus aus localStorage": Branchenstandard-Refresh-Cookie-
  Pattern für die SPA. Access-Token (15 min) nur in JS-Memory,
  Refresh-Token (7d) im httpOnly-Cookie. Auth-Middleware verweigert
  Refresh-Tokens als Bearer (type-Claim). Axios-Interceptor mit
  Single-Flight-Refresh-Retry. Tabelle der Live-Tests.

README.md:
- Tech-Stack-Auth-Zeile beschreibt jetzt die Access/Refresh-Architektur
- .env-Beispiel: JWT_EXPIRES_IN=15m + neue JWT_REFRESH_EXPIRES_IN=7d
- Production-Deployment-Hinweis: Frontend und API müssen über dieselbe
  Origin laufen (SameSite=Strict-Cookie), sonst funktioniert /auth/refresh
  cross-site nicht und User wird alle 15 min ausgeloggt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 16:12:05 +02:00
parent 9830ac29a5
commit c4e62f0f50
2 changed files with 98 additions and 3 deletions
+17 -3
View File
@@ -41,7 +41,9 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Backend**: Node.js, Express 4.x, TypeScript
- **Datenbank**: MariaDB
- **ORM**: Prisma
- **Auth**: JWT mit Rollen-basierter Zugriffskontrolle
- **Auth**: JWT-Access-Token (Memory, 15 min) + Refresh-Token im httpOnly-Cookie
(7 Tage). Rollen-basierte Zugriffskontrolle. XSS klaut maximal einen
15-min-Access-Token, der Refresh-Cookie ist JS-unzugänglich.
> **Hinweis zu Express 5:** Das Projekt verwendet bewusst Express 4.x (nicht 5.x). Express 5 ist seit Jahren in der Beta-Phase und noch nicht offiziell stable. Bei der Installation darauf achten, dass `@types/express` zur Express-Version passt:
> - Express 4.x → `@types/express@^4.17.x`
@@ -124,9 +126,14 @@ Die `.env`-Datei sollte folgende Werte enthalten:
# Database
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
# JWT
# JWT Access-/Refresh-Token-Pattern (SPA-Standard)
# Access-Token (Bearer-Header, nur im Browser-Memory, kurzlebig)
# Refresh-Token (httpOnly-Cookie, lang)
# Beide werden mit JWT_SECRET signiert; Refresh wird nur am
# /api/auth/refresh-Endpoint akzeptiert (type-Claim).
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
JWT_EXPIRES_IN="7d"
JWT_EXPIRES_IN="15m" # Access-Token-Lifetime (Default: 15m)
JWT_REFRESH_EXPIRES_IN="7d" # Refresh-Token-Lifetime (Default: 7d)
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
@@ -207,6 +214,13 @@ Plus:
- **Reverse-Proxy** (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For` hart auf
die echte Client-IP gesetzt wird (nicht nur angefügt) sonst Rate-Limit-Bypass möglich.
- **Frontend + API müssen über dieselbe Origin laufen.** Die Auth nutzt einen
httpOnly-Refresh-Cookie mit `SameSite=Strict; Path=/api/auth` wenn Frontend
und API auf getrennten Origins liegen (z.B. `crm.example.de` vs.
`api.example.de`), schickt der Browser das Cookie cross-site nicht mit
und der `/auth/refresh`-Endpoint kann den User nicht mehr nachladen
(= alle 15 min Re-Login). Beim NPM-Setup landen Frontend und API automatisch
auf derselben Domain via Proxy-Path.
- **Default-Admin-Passwort ändern** (admin@admin.com / admin).
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
durchklicken.
+81
View File
@@ -230,6 +230,87 @@ Nichts Kritisches mehr gefunden. Liefert noch:
- **Concurrent Password-Reset Race**: Token wird nach erstem Confirm
atomar gelöscht zweiter Versuch findet keinen Token. ✅
### Runde 11 Externer Pentest-Folge: Header-Hygiene + Klartext-Audit
Externer Pentest (testssl, ZAP, Nikto, Nuclei) gegen Prod-VM hat drei
Klassen Defense-in-Depth-Findings rausgespült. Reale Ausnutzbarkeit jeweils
gering, aber Audit-Bewertung fordert konsistente Header-Hygiene.
- **HSTS-Doppel-Header (18×)**: Nginx-Proxy-Manager (TLS-Terminierung) UND
Helmet schickten beide `Strict-Transport-Security` → RFC-6797-Verletzung.
Helmet's HSTS deaktiviert (`strictTransportSecurity: false`); der
Reverse-Proxy übernimmt die Policy zentral am Edge.
- **Cache-Control (~10×)**: `/api/*` → `no-store` (sensible JSON-Daten),
SPA-HTML (`/`, `/sitemap.xml`, `/robots.txt`) → `no-store, must-revalidate`
(sonst hängt der Browser nach Deploy an alter `index.html` fest),
`/assets/*.{js,css}` → `public, max-age=31536000, immutable` (Vite-Bundles
haben Content-Hash im Filename).
- **CSP No-Fallback-Direktiven (2×)**: `worker-src`, `manifest-src`,
`media-src` jetzt explizit auf `'self'`.
- **CSP `frame-ancestors`**: war `'none'`, das blockt auch same-origin-iframes
→ PDF-Vorschau im PDF-Template-Editor lädt nicht. Korrigiert auf
`'self'` (eigene App darf eigene Resourcen embeden, externe Sites bleiben
via `X-Frame-Options: SAMEORIGIN` weiter gesperrt).
- **BREACH (CVE-2013-3587)**: testssl meldet "potentially VULNERABLE,
gzip HTTP compression detected" theoretischer Side-Channel-Angriff
auf gzip-komprimierte HTTPS-Responses. Praktisch klein bei JWT-SPA (keine
reflektierten Secrets im Response), Audit-Marker bleibt aber MEDIUM.
Fix: gzip im Reverse-Proxy für `/api/*` deaktivieren (Custom-Location im
NPM, Statische Assets bleiben weiter komprimiert). README dokumentiert
Setup.
- **`Server: openresty` + `x-served-by`-Banner**: am NPM via
`more_clear_headers Server X-Served-By;` weg.
- **Audit-Log für Klartext-Passwort-Reads**: Pentest fand "HOCH (post-auth):
Klartext-Passwörter über API abrufbar" — reversible AES-256-GCM ist
by-design für das Feature "Anbieter-Login anzeigen", aber **keiner** der
sechs Endpoints (`PortalPassword`, `ContractPassword`, `SimCardCredentials`,
`InternetCredentials`, `SipCredentials`, `MailboxCredentials`) schrieb
bisher einen Audit-Log-Eintrag. Jetzt: `action: 'READ'` mit eigenem
Resource-Type + `sensitivity: CRITICAL`, Label nennt explizit "Klartext
… entschlüsselt" + Resource-ID. Damit ist im Audit-Log-Viewer jederzeit
nachvollziehbar, wer wann welches Passwort eingesehen hat
(DSGVO + Insider-Threat).
### Runde 12 JWT raus aus localStorage (XSS-Resistenz)
Externer Pentest: "JWT in `localStorage` (MITTEL)". Bei einer XSS-Lücke
irgendwo in der App wäre der Token JS-erreichbar → Angreifer könnte alle
Anbieter-Credentials abrufen. Aktuell gibt's keinen bekannten XSS-Vektor
(CSP `script-src 'self'`, React-DOM-Escaping, keine `dangerouslySetInnerHTML`
außer in Admin-befüllten HTML-Templates), aber das Defense-in-Depth-Pattern
gehört auf den SPA-Branchenstandard:
- **Access-Token**: 15 min Lifetime, lebt **nur im JavaScript-Memory**
(Modul-State in `api.ts` + `AuthContext`). Kein `localStorage` mehr.
- **Refresh-Token**: 7 Tage, im **httpOnly-Cookie** (`Secure` bei
`HTTPS_ENABLED`, `SameSite=Strict`, `Path=/api/auth`). JS hat keinen
Zugriff → XSS klaut **maximal** einen 15-min-Access-Token.
- **POST `/api/auth/refresh`**: liest Cookie, gibt neuen Access aus, rotiert
Refresh-Cookie. Prüft `tokenInvalidatedAt` (Logout/Rollenänderung =
sofortige Invalidation aller Tokens, auch des Refresh).
- **Auth-Middleware**: lehnt Refresh-Tokens (`type: 'refresh'`) als Bearer
ab → 401 `"Falscher Token-Typ"`. Defense-in-Depth gegen Token-Confusion.
- **Axios-Interceptor**: bei 401 → Single-Flight-Refresh-Retry. Original-Request
wird transparent wiederholt; concurrent 401s teilen sich denselben
Refresh-Aufruf.
- **App-Start**: ruft `/auth/refresh` auf; wenn Cookie gültig → User
automatisch eingeloggt, kein Re-Login nach Tab-Reload trotz
memory-only Access-Token.
- **Logout**: löscht Cookie + setzt `tokenInvalidatedAt` → auch parallele
Sessions auf anderen Geräten sind ungültig.
Live-Tests (alle ✅):
| Test | Resultat |
| --- | --- |
| Login | Cookie `HttpOnly; SameSite=Strict; Path=/api/auth` gesetzt, Access-Token im Body |
| API-Call mit Bearer | 200 |
| API-Call ohne Bearer | 401 |
| `/auth/refresh` mit Cookie | 200, rotiertes Cookie, neuer Access |
| `/auth/refresh` ohne Cookie | 401 |
| Refresh-Token als Bearer benutzt | 401 „Falscher Token-Typ" |
| Logout → `/auth/refresh` | 401 (Cookie weg, tokenInvalidatedAt gesetzt) |
---
## 🔧 Geprüft + sauber (kein Bug, aber explizit getestet)