From 4e91d96b5b842d0fd3ccbe790ea07cdd1b8b094c Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 26 Apr 2026 20:14:20 +0200 Subject: [PATCH] Security-Hardening Runde 6: Customer-Liste-Leak + XFF-Bypass + Vollmacht-Validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiefer Live-Pentest deckte 3 weitere Schwachstellen: 🚨 CRITICAL: GET /api/customers leakte komplette Kundendatenbank - Stage-4 hatte canAccessCustomer auf den Single-Endpoint angewendet, der List-Endpoint hatte nur den Daten-Sanitizer (filtert Passwort-Hashes) aber keinen Portal-Filter. Folge: Portal-Kunde sah ALLE Kunden mit Namen, E-Mails, customerNumber etc. – DSGVO-relevant. - Fix: getCustomers filtert für Portal-User auf eigene + vertretene IDs. 🚨 HIGH: Rate-Limit-Bypass via X-Forwarded-For - `trust proxy = 1` hat jedem XFF-Wert vertraut. 12+ Logins mit rotierender XFF-IP gingen ohne 429 durch. - Fix: `trust proxy = 'loopback'` – XFF nur noch von 127.0.0.1 / ::1 akzeptiert (= lokaler Reverse-Proxy). - Plus: LISTEN_ADDR-Default 127.0.0.1 in Production, damit das Backend nicht von außen direkt ansprechbar ist. 🛡 MEDIUM: Self-Grant + Existence-Disclosure in toggleMyAuthorization - Portal-User konnte: a) sich selbst Vollmacht erteilen (customerId=representativeId=1) b) Authorization-Records für nicht-existierende customerIds anlegen (scheitert erst am DB-Constraint mit vollem Prisma-Stack-Leak) c) Customer-IDs durch 404-vs-403-Differenzen enumerieren. - Fix: Self-Grant 400. Existenz + aktive CustomerRepresentative-Beziehung in einem Query – Non-Existent / Non-Related geben identisch 403. Prisma-Error-Stacks generisch ersetzt. Live-verifiziert: Customer-Liste filtert, Self-Grant 400, Existence-Probing dicht. Geprüft + sauber (Runde 6, kein Bug): - Prototype Pollution Login-Body - HTTP-Method-Override-Header - Path-Traversal Backup-Name (Regex blockt) - Developer-Routes existieren nicht - Email-Endpoints mit fremder StressfreiEmail-ID → 403 - /api/customers/:id GET liefert 403 statt 404 (kein Leak) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/customer.controller.ts | 17 +++++++- backend/src/controllers/gdpr.controller.ts | 32 +++++++++++---- backend/src/index.ts | 22 +++++++--- backend/todo.md | 41 +++++++++++++++++++ 4 files changed, 95 insertions(+), 17 deletions(-) diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index 515a1229..307bd2e9 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -29,11 +29,24 @@ export async function getCustomers(req: AuthRequest, res: Response): Promise(); + if (req.user.customerId) allowedIds.add(req.user.customerId); + const represented = (req.user as any).representedCustomerIds || []; + for (const id of represented) allowedIds.add(id); + customers = customers.filter((c) => allowedIds.has(c.id)); + } + // Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false; const sanitized = canSeePasswords - ? sanitizeCustomers(result.customers as any) - : (result.customers as any[]).map((c) => sanitizeCustomerStrict(c)).filter(Boolean); + ? sanitizeCustomers(customers) + : customers.map((c) => sanitizeCustomerStrict(c)).filter(Boolean); res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse); } catch (error) { res.status(500).json({ diff --git a/backend/src/controllers/gdpr.controller.ts b/backend/src/controllers/gdpr.controller.ts index bf70d41e..8712b19c 100644 --- a/backend/src/controllers/gdpr.controller.ts +++ b/backend/src/controllers/gdpr.controller.ts @@ -970,12 +970,27 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) { const representativeId = parseInt(req.params.representativeId); const { grant } = req.body; - // Vertreter-Name laden - const representative = await prisma.customer.findUnique({ - where: { id: representativeId }, - select: { firstName: true, lastName: true }, + // Validierungen: + // 1) Self-Grant verhindern (sinnlos und schafft Datenmüll). + if (representativeId === user.customerId) { + return res.status(400).json({ success: false, error: 'Kein Self-Grant möglich' }); + } + // 2) Existenz + aktives Vertreter-Verhältnis in EINEM Lookup prüfen. + // Beide Fälle (representative existiert nicht / keine aktive Beziehung) + // geben identisch 403, damit ein Angreifer keine Customer-IDs aus der + // DB enumerieren kann (kein 404-vs-403-Disclosure). + const relation = await prisma.customerRepresentative.findFirst({ + where: { customerId: user.customerId, representativeId, isActive: true }, + include: { representative: { select: { firstName: true, lastName: true } } }, }); - const repName = representative ? `${representative.firstName} ${representative.lastName}` : `#${representativeId}`; + if (!relation) { + return res.status(403).json({ + success: false, + error: 'Kein Vertreter-Verhältnis – Vollmacht nicht erlaubt', + }); + } + + const repName = `${relation.representative.firstName} ${relation.representative.lastName}`; let auth; if (grant) { @@ -991,10 +1006,9 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) { res.json({ success: true, data: auth }); } catch (error) { console.error('Fehler beim Ändern der Vollmacht:', error); - res.status(400).json({ - success: false, - error: error instanceof Error ? error.message : 'Fehler beim Ändern', - }); + // Generische Fehlermeldung – Prisma-Errors enthalten Pfad/Schema und + // sollten nicht an Endkunden geleakt werden. + res.status(400).json({ success: false, error: 'Vollmacht konnte nicht aktualisiert werden' }); } } diff --git a/backend/src/index.ts b/backend/src/index.ts index 8ae60823..ee0474fc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -58,10 +58,15 @@ const app = express(); const PORT = process.env.PORT || 3001; // Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost. -// `trust proxy = 1` = dem ersten Hop X-Forwarded-For vertrauen (damit req.ip -// die echte Client-IP ist). Wichtig für express-rate-limit, sonst teilen sich -// alle Requests dieselbe Proxy-IP und das Rate-Limit ist unwirksam. -app.set('trust proxy', 1); +// `trust proxy = 'loopback'` vertraut nur Connections von 127.0.0.1 / ::1 +// (= lokaler Reverse-Proxy). Damit kann ein Angreifer mit DIREKTEM Zugriff +// auf das Backend nicht via X-Forwarded-For den Rate-Limiter umgehen, +// während gleichzeitig der lokale Reverse-Proxy die echte Client-IP liefern darf. +// +// WICHTIG für Production: Backend nur auf 127.0.0.1 lauschen lassen +// (LISTEN_ADDR=127.0.0.1) – sonst kann ein direkter Connect von außen +// trotzdem als loopback gelten, falls das Routing das so durchstellt. +app.set('trust proxy', 'loopback'); // ==================== SECURITY MIDDLEWARE ==================== @@ -174,8 +179,13 @@ app.use((err: Error & { status?: number; type?: string }, req: express.Request, res.status(status).json({ success: false, error: message }); }); -app.listen(PORT, () => { - console.log(`Server läuft auf Port ${PORT}`); +// Listen-Adresse: in Production typischerweise 127.0.0.1 (nur lokaler +// Reverse-Proxy soll connecten dürfen). LISTEN_ADDR per Env überschreibbar. +const LISTEN_ADDR = process.env.LISTEN_ADDR + || (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0'); + +app.listen(PORT as number, LISTEN_ADDR, () => { + console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`); // Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten startBirthdayScheduler(); startContractStatusScheduler(); diff --git a/backend/todo.md b/backend/todo.md index a17400ec..5c284d26 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -141,6 +141,47 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung - Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr) - SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller) - bcrypt cost 10 → 12 (OWASP 2026) + - **Runde 6 – Tiefer Live-Pentest (auf Wunsch des Users, „bevor andere es tun"):** + - 🚨 **`GET /api/customers` leakte als Portal-User die komplette + Kundendatenbank** (alle Namen, E-Mails, customerNumber etc.). Der + Single-Endpoint war Stage 4 mit `canAccessCustomer` gefixt, der List- + Endpoint nicht. Jetzt: Portal-User bekommt nur eigene + vertretene + Kunden (Filter im Controller). + - 🚨 **Rate-Limit-Bypass via `X-Forwarded-For`**: 12+ Login-Versuche + mit rotierenden XFF-Werten gingen alle durch ohne 429. `trust proxy = 1` + hat naiv jedem XFF-Wert vertraut. Jetzt: `trust proxy = 'loopback'` – + XFF wird nur akzeptiert wenn die Connection von 127.0.0.1 / ::1 kommt + (= lokaler Reverse-Proxy). Plus: `LISTEN_ADDR=127.0.0.1` in Production- + Default, damit das Backend nicht direkt von außen ansprechbar ist. + - **Self-Grant + Existence-Disclosure in `toggleMyAuthorization`**: + - Portal-User konnte sich selbst Vollmacht erteilen (1→1) und + Datensätze für beliebige `representativeId`s anlegen (auch nicht- + existierende, scheiterte erst auf DB-Constraint mit Prisma-Stack-Leak). + - 404 vs 403 erlaubte Existence-Probing der gesamten customer-ID-Range. + - Fix: Self-Grant 400er. Existenz + aktives `CustomerRepresentative`- + Verhältnis in einem Query, beide Fehlfälle identisch 403. + - **Prisma-Error-Leak generisch in `toggleMyAuthorization`**: keine + Prisma-Stacks mehr im Response. + - Live-verifiziert: Customer-Liste 3 statt 3000 (jetzt nur erlaubte), + Self-Grant 400, Existence-Disclosure dicht (alle 403 uniform), Auth + auf `/api/customers/:id` 200/403 (kein 404-Leak). + + **Geprüft + sauber (Runde 6):** + - Prototype Pollution beim Login → kein Effekt + - HTTP-Method-Override via Header → ignoriert + - Path-Traversal in Backup-Name → durch Regex blockiert + - Developer-Routes existieren nicht (404) + - Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403 + - Self-grant Vollmacht via `customers/X/representatives` → 403 (perm) + - `/api/customers/:id` GET: 200 für eigene, 403 sonst (kein 404-Leak) + + **Offen für v1.1:** + - `/api/contracts/:id` GET liefert 404 für nicht-existente IDs (Existence- + Probing). Da contractIds aber nicht direkt mit personenbezogenen Daten + korrelieren, niedrig-Prio. Vereinheitlichung auf 403 wäre sauberer. + - Prisma-Error-Leaks in anderen Admin-Endpoints (z.B. `addInvoice` bei + Validation-Fehler) – Defense-in-Depth-Kandidat. + - **Runde 5 – Hack-Das-Ding-Audit (Live-Pentest + 3 parallele Audit-Agents):** - 🚨 **`/api/uploads/*` war OHNE AUTH erreichbar** (DSGVO-GAU!) – jetzt hinter `authenticate`. Direkte -Links nutzen `?token=...` Query-Parameter,