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:
parent
8be9baee84
commit
4e91d96b5b
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue