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,