Security-Hardening Runde 9: Pentest Runde 5

KRITISCH – change-initial-portal-password ohne mustChange-Pflicht-Check:
Jeder Portal-User konnte jederzeit sein Passwort ohne Kenntnis des
alten ersetzen (XSS-/Token-Hijack-Eskalation). Endpoint war NUR für
den OTP-Erst-Login gedacht, prüfte aber das Flag nicht. Fix: Customer
laden, portalPasswordMustChange=true erzwingen, sonst 403.

NIEDRIG – consentHash leakte über GET /customers/🆔
Hash ist Pseudo-Credential für den öffentlichen Consent-Link. Jetzt
in SENSITIVE_CUSTOMER_FIELDS (sanitize.ts) → wird aus jeder customer-
Response gestrippt. Wer ihn legitim braucht, holt ihn über
/gdpr/customer/:id/consent-status.

NIEDRIG – Public consent-grant Response leakte CustomerConsent-Records:
POST /api/public/consent/:hash/grant gab volle Records inkl. ipAddress
und createdBy (Kunden-Name) zurück. Auf { granted: <count> } reduziert
– Frontend liest eh nur success.

Live-verifiziert:
- Change-Initial ohne Flag → 403; mit Flag → 200; danach Flag=false →
  erneuter Aufruf 403
- GET /customers/3 → consentHash null, portalPasswordHash null
- /gdpr/customer/3/consent-status → consentHash weiterhin sichtbar
- Public-Grant-Response: {granted: 4}, keine ipAddress/createdBy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 22:57:09 +02:00
parent 75c833500e
commit 38c2d82c02
4 changed files with 58 additions and 1 deletions
@@ -418,6 +418,20 @@ export async function changeInitialPortalPassword(req: AuthRequest, res: Respons
} as ApiResponse);
return;
}
// Pflicht-Check: NUR im Einmalpasswort-Flow erlaubt. Sonst könnte jeder
// eingeloggte Portal-User sein Passwort ohne Kenntnis des alten ändern
// (z.B. nach XSS-Token-Diebstahl). Pentest Runde 5 (2026-05-16) KRITISCH.
const customer = await prisma.customer.findUnique({
where: { id: req.user.customerId },
select: { portalPasswordMustChange: true },
});
if (!customer?.portalPasswordMustChange) {
res.status(403).json({
success: false,
error: 'Nicht erlaubt',
} as ApiResponse);
return;
}
const { newPassword } = req.body || {};
if (!newPassword || typeof newPassword !== 'string') {
res.status(400).json({
@@ -138,7 +138,13 @@ export async function grantAllConsents(req: Request, res: Response) {
}
}
res.json({ success: true, data: results });
// Minimal-Response: NUR die Anzahl + Status. Kein ipAddress, kein createdBy,
// keine internen IDs das war früher der volle CustomerConsent-Record und
// hat unnötig Daten geleakt (Pentest Runde 5, 2026-05-16).
res.json({
success: true,
data: { granted: results.length },
});
} catch (error: any) {
console.error('Fehler beim Erteilen der Einwilligungen:', error);
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
+6
View File
@@ -9,6 +9,12 @@ const SENSITIVE_CUSTOMER_FIELDS = [
'portalPasswordHash',
'portalPasswordResetToken',
'portalPasswordResetExpiresAt',
// consentHash ist ein Pseudo-Credential für den öffentlichen Consent-Link
// (jeder mit dem Hash kann Einwilligungen erteilen + Name/Kundennummer
// anzeigen). Über GET /customers/:id darf es nicht raus. Wer ihn legitim
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
'consentHash',
] as const;
const SENSITIVE_USER_FIELDS = [