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
+107
View File
@@ -0,0 +1,107 @@
/**
* Access-Control-Helper für Portal-Kunden-Isolation.
*
* Portal-Kunden haben die Permission `contracts:read` / `customers:read`, damit
* sie ihre eigenen Daten sehen können. Damit sie aber NICHT fremde Daten über
* geratene IDs abrufen (IDOR), muss bei jedem Endpoint der eine sensible
* Ressource (Vertrag, Rechnung, Passwort, ...) zurückliefert, der Kunde auf
* Besitz/Vollmacht geprüft werden.
*/
import { Response } from 'express';
import prisma from '../lib/prisma.js';
import * as authorizationService from '../services/authorization.service.js';
import { AuthRequest } from '../types/index.js';
/**
* Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf.
* - Mitarbeiter/Admin mit customers:read / contracts:read: ja, immer
* - Portal-Kunde: nur wenn contract.customerId = eigener customerId ODER
* wenn er einen Vertreter für diesen Kunden ist MIT gültiger Vollmacht
*
* @returns true = erlaubt, false = Zugriff verweigert (Response wurde bereits gesendet)
*/
export async function canAccessContract(
req: AuthRequest,
res: Response,
contractId: number,
): Promise<boolean> {
// Nicht-Portal-User (Mitarbeiter/Admin) kommen hier immer durch, wenn sie die Permission haben
if (!req.user?.isCustomerPortal) {
return true;
}
if (!req.user.customerId) {
res.status(403).json({ success: false, error: 'Kein Zugriff' });
return false;
}
// Vertrag laden, Besitzer-ID prüfen
const contract = await prisma.contract.findUnique({
where: { id: contractId },
select: { customerId: true },
});
if (!contract) {
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' });
return false;
}
// Eigene Verträge = immer erlaubt
if (contract.customerId === req.user.customerId) {
return true;
}
// Fremde Verträge nur mit aktiver Vollmacht
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
if (!representedIds.includes(contract.customerId)) {
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' });
return false;
}
const hasAuth = await authorizationService.hasAuthorization(
contract.customerId,
req.user.customerId,
);
if (!hasAuth) {
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
return false;
}
return true;
}
/**
* Prüft Zugriff auf einen Kunden (analog zu canAccessContract).
*/
export async function canAccessCustomer(
req: AuthRequest,
res: Response,
customerId: number,
): Promise<boolean> {
if (!req.user?.isCustomerPortal) {
return true;
}
if (!req.user.customerId) {
res.status(403).json({ success: false, error: 'Kein Zugriff' });
return false;
}
if (customerId === req.user.customerId) {
return true;
}
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
if (!representedIds.includes(customerId)) {
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' });
return false;
}
const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId);
if (!hasAuth) {
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
return false;
}
return true;
}
+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;
}