Security-Hardening Runde 16: KRITISCH – Update-Responses sanitisieren

Pentest Runde 15:

20.3 KRITISCH:
PUT /customers/:id gab portalPasswordHash (bcrypt $2a$12$…) im
Response zurück. updateCustomer reichte das rohe Service-Output
ohne sanitize-Aufruf durch.

20.4 HOCH (gleiche Klasse):
PUT-Response leakte portalPasswordResetToken, portalPasswordMustChange,
consentHash, portalTokenInvalidatedAt.

Fix:
- updateCustomer + createCustomer rufen sanitizeCustomer bzw.
  sanitizeCustomerStrict je nach customers:update-Permission.
- updateContract + createContract + createFollowUp + createRenewal
  analog mit sanitizeContract / sanitizeContractStrict je nach
  isCustomerPortal.
- portalPasswordMustChange + portalTokenInvalidatedAt von
  PORTAL_HIDDEN_CUSTOMER_FIELDS zu SENSITIVE_CUSTOMER_FIELDS
  hochgezogen → greift auch in normaler sanitizeCustomer
  (Admin-Sicht).

Live-verifiziert:
- Admin PUT /customers/3 → 0 Leaks von Hash/Token/Expires/MustChange/
  consentHash/TokenInvalidatedAt; portalPasswordEncrypted bleibt
  für Admin sichtbar (UI-Workflow, separater Endpoint mit Audit)
- POST /customers → 0 Leaks
- Portal-User GET /customers/3 → 0 Leaks auch bei
  portalPasswordEncrypted/notes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:43:45 +02:00
parent 5ffd1a4d2c
commit 373fab8e83
4 changed files with 72 additions and 9 deletions
+17 -5
View File
@@ -109,7 +109,7 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
}
}
export async function createContract(req: Request, res: Response): Promise<void> {
export async function createContract(req: AuthRequest, res: Response): Promise<void> {
try {
// Input-Validierung: type + customerId sind Pflicht, sonst stürzte der
// Service mit einer kryptischen JS-Message ab (Pentest Runde 12, INFO).
@@ -129,7 +129,9 @@ export async function createContract(req: Request, res: Response): Promise<void>
label: `Vertrag ${contract.contractNumber} angelegt`,
customerId: contract.customerId,
});
res.status(201).json({ success: true, data: contract } as ApiResponse);
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -201,7 +203,13 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
customerId: before?.customerId,
});
res.json({ success: true, data: contract } as ApiResponse);
// Response sanitisieren sonst leakt portalPasswordEncrypted etc.
// (Pentest Runde 15, gleiche Klasse wie 20.3 für Customer).
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal
? sanitizeContractStrict(contract as any)
: sanitizeContract(contract as any);
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -270,7 +278,9 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
customerId: contract.customerId,
});
res.status(201).json({ success: true, data: contract } as ApiResponse);
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -323,7 +333,9 @@ export async function createRenewal(req: AuthRequest, res: Response): Promise<vo
customerId: contract.customerId,
});
res.status(201).json({ success: true, data: contract } as ApiResponse);
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
+16 -2
View File
@@ -90,7 +90,14 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
label: `Kunde ${customer.customerNumber} angelegt (${customer.firstName} ${customer.lastName})`,
customerId: customer.id,
});
res.status(201).json({ success: true, data: customer } as ApiResponse);
// Response sanitisieren (Pentest Runde 15, 20.3/20.4): die Service-
// Funktion gibt das rohe DB-Objekt mit portalPasswordHash + Reset-Token
// zurück. Ohne sanitize-Aufruf leakte das beim Erstellen + Update.
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomer(customer as any)
: sanitizeCustomerStrict(customer as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
@@ -169,7 +176,14 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
}
}
res.json({ success: true, data: customer } as ApiResponse);
// Response sanitisieren sonst leakt portalPasswordHash +
// portalPasswordResetToken + consentHash + portalPasswordMustChange.
// Pentest Runde 15 (20.3 KRITISCH, 20.4 HOCH).
const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomer(customer as any)
: sanitizeCustomerStrict(customer as any);
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
console.error('Update customer error:', error);
res.status(400).json({
+10 -2
View File
@@ -15,6 +15,14 @@ const SENSITIVE_CUSTOMER_FIELDS = [
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
'consentHash',
// Session-/OTP-State Pentest Runde 15 (2026-05-18, 20.4 HOCH): zeigt
// einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und
// wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne
// Auth-Bypass, aber unnötig. Wenn Admin diese Information legitim
// braucht (z.B. UI-Hinweis "OTP wurde noch nicht eingelöst"), führen
// wir bei Bedarf einen eigenen Endpoint ein.
'portalPasswordMustChange',
'portalTokenInvalidatedAt',
] as const;
// Zusätzliche Felder die Portal-User nicht in ihrer Customer-Response sehen
@@ -22,9 +30,9 @@ const SENSITIVE_CUSTOMER_FIELDS = [
// unnötige Informationsleckage über den DB-Aufbau.
// Pentest Runde 7 (2026-05-17), MEDIUM.
const PORTAL_HIDDEN_CUSTOMER_FIELDS = [
'portalTokenInvalidatedAt',
// portalTokenInvalidatedAt + portalPasswordMustChange sind jetzt in
// SENSITIVE_CUSTOMER_FIELDS (immer raus), nicht mehr nur für Portal.
'portalLastLogin',
'portalPasswordMustChange',
'lastBirthdayGreetingYear',
// privacyPolicyPath etc. sind interne Datei-Pfade Portal nutzt
// dedizierte PDF-Endpoints, nicht den Pfad direkt