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
+68
View File
@@ -0,0 +1,68 @@
/**
* Sanitize-Helpers: entfernen sensible Felder aus DB-Ergebnissen, bevor sie
* als API-Response rausgehen. Zentrale Stelle, damit keine Passwort-Hashes,
* Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken.
*/
// Felder die NIE in einer API-Response an den Client gehen dürfen
const SENSITIVE_CUSTOMER_FIELDS = [
'portalPasswordHash',
'portalPasswordResetToken',
'portalPasswordResetExpiresAt',
] as const;
const SENSITIVE_USER_FIELDS = [
'password',
'passwordResetToken',
'passwordResetExpiresAt',
'tokenInvalidatedAt',
] as const;
/**
* Entfernt Passwort-Hash, Reset-Token etc. aus einem Customer-Objekt.
* `portalPasswordEncrypted` bleibt nur drin, wenn der Caller Admin-Rechte hat
* (wird in einem zweiten Schritt vom Controller gemacht). Dieser Helper entfernt
* es standardmäßig.
*/
export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T | null): T | null {
if (!customer) return customer;
const copy = { ...customer };
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
delete copy[field];
}
// portalPasswordEncrypted bleibt hier zunächst drin, damit Mitarbeiter das
// Portal-Passwort ggf. in der UI anzeigen können. Wird per requirePermission
// auf 'customers:update' implizit gesichert.
return copy;
}
/**
* Entfernt portalPasswordEncrypted zusätzlich zu den anderen sensiblen Feldern.
* Für Kontexte in denen der Caller KEIN Admin ist (z.B. Portal-Kunde).
*/
export function sanitizeCustomerStrict<T extends Record<string, unknown>>(customer: T | null): T | null {
if (!customer) return customer;
const copy = sanitizeCustomer(customer) as Record<string, unknown> | null;
if (!copy) return null;
delete copy.portalPasswordEncrypted;
return copy as T;
}
/**
* Sanitize-Liste von Customers.
*/
export function sanitizeCustomers<T extends Record<string, unknown>>(customers: T[]): T[] {
return customers.map((c) => sanitizeCustomer(c)).filter((c): c is T => c !== null);
}
/**
* Sanitize User-Objekt für API-Responses.
*/
export function sanitizeUser<T extends Record<string, unknown>>(user: T | null): T | null {
if (!user) return user;
const copy = { ...user };
for (const field of SENSITIVE_USER_FIELDS) {
delete copy[field];
}
return copy;
}