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 = [
+31
View File
@@ -97,6 +97,37 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🚨 Pentest Runde 5 KRITISCH: change-initial-portal-password ohne Pflicht-Check**
- **Realer Angriff**: Jeder Portal-User konnte jederzeit mit
seinem eingeloggten Token `POST /api/auth/change-initial-portal-
password` aufrufen und das eigene Passwort ohne Kenntnis des
alten ersetzen. Der OTP-Flow-Endpoint hatte den Check
`portalPasswordMustChange === true` nicht.
- **Konsequenz**: Bei XSS oder kurzlebigem Token-Diebstahl konnte
ein Angreifer das Passwort dauerhaft übernehmen.
- **Fix**: Eine Zeile in `auth.controller.ts`
`prisma.customer.findUnique` auf `portalPasswordMustChange`,
bei `false` → 403 "Nicht erlaubt".
- **Live-verifiziert**: ohne Flag → 403; mit Flag (nach
send-credentials) → 200, danach Flag automatisch zurück auf
`false` → erneuter Aufruf → 403.
- [x] **Pentest Runde 5 NIEDRIG: consentHash + Public-Grant-Response**
- `consentHash` wurde über `GET /api/customers/:id` zurückgegeben.
Der Hash ist Pseudo-Credential für den öffentlichen Consent-Link
(wer ihn hat, sieht Customer-Name + Kundennummer ohne Auth und
kann Einwilligungen erteilen). **Fix**: in
`SENSITIVE_CUSTOMER_FIELDS` aufgenommen. Wer ihn legitim braucht,
holt ihn über `/gdpr/customer/:id/consent-status` (eigener Check).
- `POST /api/public/consent/:hash/grant` gab den vollen
`CustomerConsent[]`-Array inkl. IP-Adressen und `createdBy`
(Kunden-Name) zurück. **Fix**: Response auf
`{ granted: <count> }` reduziert. Frontend nutzt eh nur
`success`-Flag.
- **Live-verifiziert**: `consentHash: null` in customer-Response,
`consentHash` weiterhin in `/gdpr/.../consent-status`,
Grant-Response liefert nur `{granted: 4}` ohne Extra-Keys.
- [x] **🚨 Pentest Runde 4 HOCH: Cockpit-IDOR (Portal-User sah ALLE Kunden)**
- **Realer Angriff**: Portal-User Max bekam mit seinem Token
`GET /api/contracts/cockpit` → komplette Vertragsliste ALLER