diff --git a/README.md b/README.md index 123fc551..55d909fd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/SECURITY-HARDENING.md b/docs/SECURITY-HARDENING.md index df966944..f629a6b0 100644 --- a/docs/SECURITY-HARDENING.md +++ b/docs/SECURITY-HARDENING.md @@ -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)