Security-Hardening Runde 6: Customer-Liste-Leak + XFF-Bypass + Vollmacht-Validation

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) <noreply@anthropic.com>
This commit is contained in:
duffyduck 2026-04-26 20:14:20 +02:00
parent 8be9baee84
commit 4e91d96b5b
4 changed files with 95 additions and 17 deletions

View File

@ -29,11 +29,24 @@ export async function getCustomers(req: AuthRequest, res: Response): Promise<voi
page: page ? parseInt(page as string) : undefined, page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined, limit: limit ? parseInt(limit as string) : undefined,
}); });
let customers = result.customers as any[];
// Portal-Kunden: Liste auf eigenen + vertretene Kunden einschränken.
// Ohne diesen Filter würde der List-Endpoint die komplette Kundendatenbank
// an einen einzelnen Portal-Account preisgeben.
if (req.user?.isCustomerPortal) {
const allowedIds = new Set<number>();
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 // Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false; const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords const sanitized = canSeePasswords
? sanitizeCustomers(result.customers as any) ? sanitizeCustomers(customers)
: (result.customers as any[]).map((c) => sanitizeCustomerStrict(c)).filter(Boolean); : customers.map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse); res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ res.status(500).json({

View File

@ -970,12 +970,27 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
const representativeId = parseInt(req.params.representativeId); const representativeId = parseInt(req.params.representativeId);
const { grant } = req.body; const { grant } = req.body;
// Vertreter-Name laden // Validierungen:
const representative = await prisma.customer.findUnique({ // 1) Self-Grant verhindern (sinnlos und schafft Datenmüll).
where: { id: representativeId }, if (representativeId === user.customerId) {
select: { firstName: true, lastName: true }, 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; let auth;
if (grant) { if (grant) {
@ -991,10 +1006,9 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
res.json({ success: true, data: auth }); res.json({ success: true, data: auth });
} catch (error) { } catch (error) {
console.error('Fehler beim Ändern der Vollmacht:', error); console.error('Fehler beim Ändern der Vollmacht:', error);
res.status(400).json({ // Generische Fehlermeldung Prisma-Errors enthalten Pfad/Schema und
success: false, // sollten nicht an Endkunden geleakt werden.
error: error instanceof Error ? error.message : 'Fehler beim Ändern', res.status(400).json({ success: false, error: 'Vollmacht konnte nicht aktualisiert werden' });
});
} }
} }

View File

@ -58,10 +58,15 @@ const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost. // 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 // `trust proxy = 'loopback'` vertraut nur Connections von 127.0.0.1 / ::1
// die echte Client-IP ist). Wichtig für express-rate-limit, sonst teilen sich // (= lokaler Reverse-Proxy). Damit kann ein Angreifer mit DIREKTEM Zugriff
// alle Requests dieselbe Proxy-IP und das Rate-Limit ist unwirksam. // auf das Backend nicht via X-Forwarded-For den Rate-Limiter umgehen,
app.set('trust proxy', 1); // 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 ==================== // ==================== 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 }); res.status(status).json({ success: false, error: message });
}); });
app.listen(PORT, () => { // Listen-Adresse: in Production typischerweise 127.0.0.1 (nur lokaler
console.log(`Server läuft auf Port ${PORT}`); // 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 // Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
startBirthdayScheduler(); startBirthdayScheduler();
startContractStatusScheduler(); startContractStatusScheduler();

View File

@ -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) - 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) - SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller)
- bcrypt cost 10 → 12 (OWASP 2026) - 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):** - **Runde 5 Hack-Das-Ding-Audit (Live-Pentest + 3 parallele Audit-Agents):**
- 🚨 **`/api/uploads/*` war OHNE AUTH erreichbar** (DSGVO-GAU!) jetzt hinter - 🚨 **`/api/uploads/*` war OHNE AUTH erreichbar** (DSGVO-GAU!) jetzt hinter
`authenticate`. Direkte <a href>-Links nutzen `?token=...` Query-Parameter, `authenticate`. Direkte <a href>-Links nutzen `?token=...` Query-Parameter,