Security-Hardening: IDOR-Fixes, XSS-Sanitizer, CORS+Helmet, Data-Exposure

Umfassender Security-Review vor öffentlichem Deployment.
Detaillierter Report in docs/SECURITY-REVIEW.md.

🔴 KRITISCHE FIXES:

1. CORS offen → jetzt nur explizite Origins (via CORS_ORIGINS env),
   in Production per default komplett aus (gleiche Origin erzwingt Browser).

2. Keine Security-Headers → helmet-Middleware hinzugefügt.
   X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, CORP.

3. JWT-Fallback-Secret entfernt. Beim Server-Start wird jetzt geprüft ob
   JWT_SECRET (min 32 Zeichen) und ENCRYPTION_KEY (exakt 64 Hex) gesetzt sind,
   sonst Fail-Fast mit klarer Fehlermeldung.

4. IDOR bei 7 Contract-Endpoints. Portal-Kunden mit 'contracts:read'
   konnten über geratene IDs fremde Daten abrufen (Passwort, SIM-PIN/PUK,
   Internet-Zugangsdaten, SIP-Credentials, Vertragsdokumente, Rechnungen).
   Neuer Helper canAccessContract() in utils/accessControl.ts in allen
   betroffenen Endpoints eingebaut. Prüft Vertrag-Besitzer + Vollmachten.

5. XSS via Email-Body. email.htmlBody wurde ungefiltert via
   dangerouslySetInnerHTML gerendert. Angreifer konnte Mail mit <script>
   schicken → Token-Diebstahl aus localStorage. Jetzt mit DOMPurify
   sanitized: verbietet script/iframe/form/inline-handler, erlaubt
   normale Formatierung + Bilder.

6. Customer-API leakte sensible Felder:
   - portalPasswordHash (bcrypt-Hash)
   - portalPasswordEncrypted (symmetrisch, mit ENCRYPTION_KEY entschlüsselbar)
   - portalPasswordResetToken (gültig 2h)
   Neuer Sanitizer in utils/sanitize.ts, angewendet in getCustomer/getCustomers.
   Admin mit customers:update darf portalPasswordEncrypted sehen (für UI-Anzeige),
   alle anderen Rollen nicht.

🟡 WICHTIGE FIXES:

7. Portal-JWT-Invalidation nach Passwort-Reset. Neues Feld
   Customer.portalTokenInvalidatedAt, wird beim Reset auf now() gesetzt.
   Auth-Middleware prüft Portal-Sessions dagegen. Alte Sessions werden
   dadurch invalidiert.

8. express.json() mit 5 MB Size-Limit (statt Default 100 KB unklar).

Neue Files:
- backend/src/utils/accessControl.ts - IDOR-Schutz
- backend/src/utils/sanitize.ts - Response-Sanitizer
- docs/SECURITY-REVIEW.md - vollständiger Report + Deployment-Checkliste

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 22:06:16 +02:00
parent 8fc050a282
commit 1c46d7345c
14 changed files with 520 additions and 29 deletions
+154
View File
@@ -0,0 +1,154 @@
# Security-Review vor 1.0.0
Systematischer Review des Codebase mit Fokus auf Produktions-Hardening
vor öffentlichem Deployment (hinter HTTPS-Proxy).
## Gefundene Probleme & Fixes
### 🔴 KRITISCH (sofort gefixt)
#### 1. CORS komplett offen
**Vorher:** `app.use(cors())` jede Origin darf Requests senden.
**Risiko:** Fremde Websites können bei eingeloggtem User Requests mit dessen
JWT durchführen (wenn Token in Cookies wäre bei localStorage weniger relevant,
aber trotzdem schlechte Praxis).
**Fix:** CORS nur für explizit konfigurierte Origins (via `CORS_ORIGINS` ENV),
in Production per Default komplett aus (Frontend läuft unter gleicher Origin).
#### 2. Keine Security-Headers (Helmet fehlt)
**Vorher:** Keine HTTP-Security-Headers gesetzt.
**Risiko:** XSS, Clickjacking, MIME-Sniffing, Missing HSTS.
**Fix:** `helmet`-Middleware aktiviert setzt automatisch:
X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS (in HTTPS),
Cross-Origin-Resource-Policy.
#### 3. JWT-Fallback-Secret
**Vorher:** `jwt.verify(token, process.env.JWT_SECRET || 'fallback-secret')`
**Risiko:** Wenn `.env` kaputt ist oder Secret leer → bekannter String
"fallback-secret" → **Tokens können gefälscht werden!**
**Fix:** Beim Server-Start wird geprüft, dass JWT_SECRET mindestens 32 Zeichen lang
und ENCRYPTION_KEY exakt 64 Hex-Zeichen hat. Sonst Abbruch mit klarer Fehlermeldung.
Fallback wurde aus dem Code entfernt.
#### 4. IDOR bei sensiblen Contract-Endpoints
**Vorher:** Portal-Kunden haben `contracts:read` Permission → können über
geratene IDs auf **fremde** Daten zugreifen:
- `GET /contracts/:id/password` → Passwort im Klartext
- `GET /contracts/simcard/:id/credentials` → PIN/PUK
- `GET /contracts/:id/internet-credentials` → Internet-Passwort
- `GET /contracts/phonenumber/:id/sip-credentials` → SIP-Passwort
- `GET /contracts/:id/documents` → Vertragsdokumente
- `GET /contracts/:id/invoices` → Rechnungen
- `POST /contracts/:id/invoices` → Rechnung zu fremdem Vertrag hinzufügen
**Fix:** Neuer Helper `canAccessContract()` in `backend/src/utils/accessControl.ts`.
Wird in allen sensiblen Endpoints aufgerufen und prüft:
- Mitarbeiter/Admin → OK
- Portal-Kunde + eigener Vertrag → OK
- Portal-Kunde + vertretener Kunde MIT gültiger Vollmacht → OK
- Sonst 403 Forbidden
#### 5. XSS via Email-Body
**Vorher:** `<div dangerouslySetInnerHTML={{ __html: email.htmlBody }} />`
**Risiko:** Ein Angreifer sendet Mail mit `<script>fetch('/api/...')`
wird im Browser des Mitarbeiters ausgeführt → JWT-Token-Diebstahl möglich.
**Fix:** DOMPurify sanitized `htmlBody` vor dem Rendern:
- Verbietet: script, style, iframe, object, embed, form, inline-handler
- Erlaubt: normale Formatierung, Bilder, Links
- Zusätzlich: target=_blank damit Links neue Tabs öffnen
#### 6. Customer-API leakt Passwort-Hashes + Reset-Tokens
**Vorher:** `getCustomer` / `getCustomers` gab alle Felder zurück inklusive:
- `portalPasswordHash` (bcrypt)
- `portalPasswordEncrypted` (symmetrisch, entschlüsselbar mit Key)
- `portalPasswordResetToken` (gültig 2h, damit könnte man das Passwort zurücksetzen)
**Fix:** Zentrale Sanitizer-Helper in `backend/src/utils/sanitize.ts`:
- `sanitizeCustomer` → entfernt Hash + Reset-Token
- `sanitizeCustomerStrict` → zusätzlich ohne Encrypted-Passwort
(für Nicht-Admin-Rollen)
- Im `getCustomer`/`getCustomers` angewendet: Admins sehen encrypted
(um Passwort in UI anzeigen zu können), alle anderen nicht.
### 🟡 WICHTIG (gefixt)
#### 7. Portal-JWT-Invalidation fehlte
**Vorher:** Nach einem Portal-Passwort-Reset blieben alte JWTs bis zum Ablauf (7d) gültig.
**Risiko:** Wenn ein Angreifer einen Token geklaut hat, konnte der Kunde das
Passwort zwar ändern, aber der Angreifer blieb eingeloggt.
**Fix:** Neues Feld `Customer.portalTokenInvalidatedAt` analog zu
`User.tokenInvalidatedAt`. Wird bei Portal-Passwort-Reset auf `now()` gesetzt.
Auth-Middleware prüft bei Portal-Sessions diesen Timestamp gegen `token.iat`.
#### 8. express.json() ohne Size-Limit
**Vorher:** Default 100KB aber unklar und nicht explizit.
**Fix:** `express.json({ limit: '5mb' })` deckt normale API-Bodies mit
eingebetteten Base64-Attachments ab, blockt aber DoS-Versuche mit 100MB-Payloads.
## Nicht kritische Findings (Empfehlungen für später)
### 🟢 Token in Query-Parameter
Für Attachment-Downloads/iframes wird das JWT als `?token=...` mitgegeben.
**Risiko:** Token landet in Server-Access-Logs, Browser-History, Referer-Headers.
**Mitigation aktuell:** JWT läuft nach 7d ab, und bei `password-reset` werden
alle Sessions gekickt.
**Bessere Lösung (später):** Kurzlebige Download-Tokens (5 Min) statt JWT direkt.
### 🟢 Upload: nur Browser-MIME-Check
Multer prüft nur den vom Browser gesendeten Content-Type. Ein Angreifer könnte
eine Shell mit `application/pdf` hochladen.
**Mitigation aktuell:**
- Uploads-Ordner hat keine Execute-Rechte (Linux-Standard)
- Dateien werden mit uniquem Namen + Original-Extension gespeichert
- Apache/Caddy served Uploads mit `Content-Disposition: attachment` inline (keine Ausführung)
**Besser (später):** Magic-Byte-Check via `file-type` npm-Paket.
### 🟢 `.env` in git history
Die initiale `.env` mit Demo-Secrets ist im ersten Commit eingecheckt.
**Risiko:** Wenn das Repo öffentlich wird, sind die Demo-Keys bekannt.
**Action:** Vor Öffentlich-Machen: `openssl rand -hex 64` für neuen JWT_SECRET
und `openssl rand -hex 32` für neuen ENCRYPTION_KEY in `.env.production`.
Optional: `git filter-repo` um `.env` aus History zu löschen.
## Deployment-Checkliste vor Go-Live
- [ ] **ENV-Vars setzen:**
- `JWT_SECRET` neu generiert (`openssl rand -hex 64`)
- `ENCRYPTION_KEY` neu generiert (`openssl rand -hex 32`)
- `NODE_ENV=production`
- `CORS_ORIGINS=https://crm.meinedomain.de` (oder leer wenn SPA unter gleicher Origin)
- `PUBLIC_URL=https://crm.meinedomain.de` (für Reset-Links in E-Mails)
- [ ] **Helmet HSTS aktiv** (automatisch mit helmet + HTTPS hinter Caddy)
- [ ] **Dependencies aktuell:** `npm audit fix` lauen lassen
- [ ] **DB-User minimal:** Prod-User darf nur INSERT/UPDATE/DELETE/SELECT auf opencrm DB,
nicht DROP/ALTER/CREATE
- [ ] **Uploads-Ordner:** chmod 750, keine Execute-Rechte
- [ ] **Backup-Job:** Crontab mit täglichem `npm run db:backup`
- [ ] **Log-Rotation:** logrotate für Node-Process-Logs
- [ ] **Monitoring:** uptime-kuma o.Ä. auf `/api/health`
- [ ] **Reverse-Proxy (Caddy) setzt:**
- HSTS (mindestens 1 Jahr)
- automatisches SSL via Let's Encrypt
- Body-Size-Limit (Caddy-Config)
## Was getestet werden MUSS (vor öffentlichem Deployment)
1. **IDOR-Tests:** Als Portal-Kunde A einloggen, fremde IDs per URL/API probieren
→ alle müssen 403 geben (siehe TESTING.md)
2. **XSS-Tests:** Test-Mail mit `<script>alert(1)</script>` in HTML-Body senden,
im Email-Client öffnen → kein Alert
3. **Rate-Limit-Tests:** 11x falsch einloggen → muss blocken
4. **Password-Reset-Tests:** Reset-Link 2x nutzen → zweites Mal fehlschlägt
## Übersicht der Code-Änderungen
| Datei | Änderung |
|---|---|
| `backend/src/index.ts` | Helmet, CORS-Config, Body-Limit, ENV-Check beim Start |
| `backend/src/middleware/auth.ts` | JWT-Fallback raus, Portal-Token-Invalidation |
| `backend/src/services/auth.service.ts` | JWT-Fallback raus, `portalTokenInvalidatedAt` setzen |
| `backend/src/utils/accessControl.ts` | **NEU** `canAccessContract`, `canAccessCustomer` |
| `backend/src/utils/sanitize.ts` | **NEU** Sanitizer für Customer/User |
| `backend/src/controllers/contract.controller.ts` | IDOR-Schutz in 5 Endpoints |
| `backend/src/controllers/invoice.controller.ts` | IDOR-Schutz in 2 Endpoints |
| `backend/src/controllers/customer.controller.ts` | Sanitizer in getCustomer/getCustomers |
| `backend/prisma/schema.prisma` | `Customer.portalTokenInvalidatedAt` |
| `frontend/src/components/email/EmailDetail.tsx` | DOMPurify für htmlBody |