From 38c2d82c02c00f0f5cb1cfcfc286f22ca241ff52 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 16 May 2026 22:57:09 +0200 Subject: [PATCH] Security-Hardening Runde 9: Pentest Runde 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/:id: 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: } 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) --- backend/src/controllers/auth.controller.ts | 14 +++++++++ .../controllers/consent-public.controller.ts | 8 ++++- backend/src/utils/sanitize.ts | 6 ++++ docs/todo.md | 31 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index b5a6ecff..feeec18f 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -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({ diff --git a/backend/src/controllers/consent-public.controller.ts b/backend/src/controllers/consent-public.controller.ts index 7878a03e..c0807b87 100644 --- a/backend/src/controllers/consent-public.controller.ts +++ b/backend/src/controllers/consent-public.controller.ts @@ -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' }); diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts index 77310b0b..55cba883 100644 --- a/backend/src/utils/sanitize.ts +++ b/backend/src/utils/sanitize.ts @@ -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 = [ diff --git a/docs/todo.md b/docs/todo.md index 4eb9bb06..5966c4c9 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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: }` 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