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:
@@ -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' });
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user