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 62debf19d0
commit 0a79e6dcf1
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;
}