diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index c705d5a6..21e0e8ed 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -921,7 +921,22 @@ export async function getPortalSettings(req: AuthRequest, res: Response): Promis export async function updatePortalSettings(req: Request, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); - const { portalEnabled, portalEmail } = req.body; + // `password` (oder password-ähnliche Felder) gehören NICHT in den + // Settings-Update. Sonst denkt der Client, sein Passwort wurde gesetzt + // (HTTP 200), während das Feld stillschweigend ignoriert wird. Wer + // ein Passwort setzen will, nutzt POST /portal/password mit + // Komplexitäts-Check. (Pentest-Befund.) + const body = req.body || {}; + const forbidden = ['password', 'portalPassword', 'portalPasswordHash', 'portalPasswordEncrypted']; + const offending = forbidden.filter((k) => k in body); + if (offending.length > 0) { + res.status(400).json({ + success: false, + error: `Felder nicht erlaubt: ${offending.join(', ')}. Bitte POST /customers/${customerId}/portal/password nutzen.`, + } as ApiResponse); + return; + } + const { portalEnabled, portalEmail } = body; // Vorherigen Stand laden für Audit const before = await prisma.customer.findUnique({ diff --git a/backend/src/controllers/rateLimitAdmin.controller.ts b/backend/src/controllers/rateLimitAdmin.controller.ts index 472600f2..191e2306 100644 --- a/backend/src/controllers/rateLimitAdmin.controller.ts +++ b/backend/src/controllers/rateLimitAdmin.controller.ts @@ -4,24 +4,30 @@ import { AuthRequest, ApiResponse } from '../types/index.js'; import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js'; import { logChange } from '../services/audit.service.js'; -// Login-Rate-Limiter sperrt 15 Minuten. Wir betrachten alles, was innerhalb -// dieses Fensters einen RATE_LIMIT_HIT erzeugt hat, als „aktuell gesperrt". const LOGIN_WINDOW_MS = 15 * 60 * 1000; +type ActiveLock = { + ipAddress: string; + email: string | null; // null = Passwort-Reset oder Login ohne Email + lastHit: Date; + hitCount: number; + lastEndpoint: string | null; + limiters: string[]; // 'login' / 'password-reset' +}; + +function lockKey(ip: string, email: string | null): string { + return `${ip}|${(email || '').toLowerCase()}`; +} + /** - * Listet alle IP-Adressen, die in den letzten 15 Minuten den Login-Rate- - * Limiter ausgelöst haben. Pro IP: letzter Versuch, Anzahl Hits, - * (letzte) versuchte E-Mail. + * Listet aktive Sperren als (IP, Email)-Tupel. Jedes Tupel ist ein eigener + * Bucket im Limiter – Reset gilt exakt für dieses Paar. */ export async function getActiveRateLimits(req: AuthRequest, res: Response): Promise { try { const since = new Date(Date.now() - LOGIN_WINDOW_MS); const events = await prisma.securityEvent.findMany({ - where: { - type: 'RATE_LIMIT_HIT', - createdAt: { gte: since }, - ipAddress: { not: null }, - }, + where: { type: 'RATE_LIMIT_HIT', createdAt: { gte: since } }, orderBy: { createdAt: 'desc' }, select: { ipAddress: true, @@ -32,46 +38,40 @@ export async function getActiveRateLimits(req: AuthRequest, res: Response): Prom }, }); - // Pro IP gruppieren: lastHit + hitCount + zuletzt versuchte Email + Limiter-Typ - type Active = { - ipAddress: string; - lastHit: Date; - hitCount: number; - lastEmail: string | null; - lastEndpoint: string | null; - limiters: string[]; // 'login' / 'password-reset' - }; - const byIp = new Map(); + const byKey = new Map(); for (const ev of events) { - const ip = ev.ipAddress!; + const ip = ev.ipAddress || 'unknown'; + const email = (ev.userEmail || '').toLowerCase() || null; const limiter = (ev.details as any)?.limiter ?? 'unknown'; - const existing = byIp.get(ip); + const key = lockKey(ip, email); + const existing = byKey.get(key); if (existing) { existing.hitCount += 1; if (!existing.limiters.includes(limiter)) existing.limiters.push(limiter); } else { - byIp.set(ip, { + byKey.set(key, { ipAddress: ip, + email, lastHit: ev.createdAt, hitCount: 1, - lastEmail: ev.userEmail, lastEndpoint: ev.endpoint, limiters: [limiter], }); } } - // Bereits manuell freigegebene IPs aus der Anzeige rauswerfen: wenn der - // letzte Reset (= Audit-Log-Eintrag) NACH dem letzten Hit liegt, ist die - // IP nicht mehr gesperrt. SecurityEvents sind unveränderlich, also brauchen - // wir diesen Reset-Marker, sonst bleibt eine bereits freigegebene IP - // weiterhin im Bildschirm hängen, bis das 15-Min-Fenster abgelaufen ist. - const candidateIps = Array.from(byIp.keys()); - if (candidateIps.length > 0) { + // Bereits manuell freigegebene aus der Liste werfen. Reset-Audit-Logs + // nutzen resourceId = "|" (gleicher Schlüssel wie Bucket). + const candidates = Array.from(byKey.entries()).map(([k, e]) => ({ + mapKey: k, + resourceId: k, + lastHit: e.lastHit, + })); + if (candidates.length > 0) { const recentResets = await prisma.auditLog.findMany({ where: { resourceType: 'RateLimit', - resourceId: { in: candidateIps }, + resourceId: { in: candidates.map((c) => c.resourceId) }, createdAt: { gte: since }, }, select: { resourceId: true, createdAt: true }, @@ -79,20 +79,15 @@ export async function getActiveRateLimits(req: AuthRequest, res: Response): Prom }); const resetMap = new Map(); for (const r of recentResets) { - if (r.resourceId && !resetMap.has(r.resourceId)) { - resetMap.set(r.resourceId, r.createdAt); - } + if (r.resourceId && !resetMap.has(r.resourceId)) resetMap.set(r.resourceId, r.createdAt); } - for (const ip of candidateIps) { - const reset = resetMap.get(ip); - const entry = byIp.get(ip)!; - if (reset && reset >= entry.lastHit) { - byIp.delete(ip); - } + for (const c of candidates) { + const reset = resetMap.get(c.resourceId); + if (reset && reset >= c.lastHit) byKey.delete(c.mapKey); } } - const list = Array.from(byIp.values()).sort( + const list = Array.from(byKey.values()).sort( (a, b) => b.lastHit.getTime() - a.lastHit.getTime(), ); res.json({ success: true, data: list } as ApiResponse); @@ -106,34 +101,50 @@ export async function getActiveRateLimits(req: AuthRequest, res: Response): Prom } /** - * Setzt das Rate-Limit für eine konkrete IP zurück (Login + Password-Reset). - * Idempotent: wenn die IP nicht im Store ist, bleibt der Aufruf einfach - * ohne Wirkung. + * Reset für ein konkretes (IP, Email)-Tupel. Body MUSS ipAddress enthalten + * + optional email. Bei fehlender Email wird `|` reseted + * (für Login-Versuche mit leerem Body). Für Passwort-Reset-Limit wird der + * IP-only-Key (alter Stil) zusätzlich reseted. */ export async function resetRateLimit(req: AuthRequest, res: Response): Promise { try { const ip = (req.body?.ipAddress || '').toString().trim(); + const email = (req.body?.email || '').toString().trim().toLowerCase(); + if (!ip) { res.status(400).json({ success: false, - error: 'IP-Adresse fehlt', + error: 'IP-Adresse erforderlich', } as ApiResponse); return; } - // express-rate-limit v7 exponiert resetKey() auf dem Middleware-Handle. - // Falls die IP nicht im Store ist, ist das ein No-Op. - await (loginRateLimiter as any).resetKey?.(ip); + + // Login-Tuple-Bucket: `${ip}|${email}` bzw. `${ip}|` + const loginKey = email ? `${ip}|${email}` : `${ip}|`; + await (loginRateLimiter as any).resetKey?.(loginKey); + + // Passwort-Reset-Limit ist (noch) IP-only – auch zurücksetzen await (passwordResetRateLimiter as any).resetKey?.(ip); + // Audit-Resource-ID = der Bucket-Key, damit getActiveRateLimits den + // Eintrag aus der Anzeige filtern kann. + const audited = `${ip}|${email || ''}`; await logChange({ req, action: 'UPDATE', resourceType: 'RateLimit', - resourceId: ip, - label: `Rate-Limit für IP ${ip} manuell freigegeben`, + resourceId: audited, + label: email + ? `Rate-Limit für (IP ${ip}, Email ${email}) manuell freigegeben` + : `Rate-Limit für IP ${ip} manuell freigegeben`, }); - res.json({ success: true, message: `Rate-Limit für ${ip} freigegeben` } as ApiResponse); + res.json({ + success: true, + message: email + ? `Rate-Limit für (${ip}, ${email}) freigegeben` + : `Rate-Limit für ${ip} freigegeben`, + } as ApiResponse); } catch (error) { console.error('resetRateLimit error:', error); res.status(500).json({ diff --git a/backend/src/middleware/rateLimit.ts b/backend/src/middleware/rateLimit.ts index 856c5adc..835d5a25 100644 --- a/backend/src/middleware/rateLimit.ts +++ b/backend/src/middleware/rateLimit.ts @@ -25,20 +25,38 @@ function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') { } /** - * Login: 10 Versuche pro 15 Minuten pro IP. - * Nach Überschreitung: 15 Min Sperre für diese IP. + * Login-Limiter: 10 Fehlversuche pro 15 min PRO (IP + Email)-Tuple. + * + * Das Bucket ist gezielt das Paar, nicht IP allein und nicht Email allein: + * - IP allein wäre kein Schutz: ein Angreifer wechselt Proxy, hat wieder + * 10 freie Versuche gegen den gleichen Account. + * - Email allein erzeugt False-Positives (Familie hinter NAT: Max + * vertippt sich → Nina kommt von gleicher IP nicht mehr rein) und + * macht Account-Lockout-DoS möglich (Angreifer sperrt fremde Accounts + * aus, indem er von beliebigen IPs falsche PWs gegen sie probiert). + * - Tuple (IP, Email): Max kann sich nicht mehr einloggen, Nina von + * gleicher IP schon. Max von einer anderen IP auch, solange er das + * richtige PW hat – ihre eigene Spur in den Buckets ist sauber. + * + * keyGenerator → `${ip}|${email-lowercase}`. Bei fehlender Email + * (z.B. komplett leerer Body) Fallback nur auf IP, damit kein + * Single-Shared-Bucket entsteht. */ export const loginRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 Minuten - limit: 10, // Max. 10 Versuche pro Zeitfenster + windowMs: 15 * 60 * 1000, + limit: 10, standardHeaders: 'draft-7', legacyHeaders: false, message: { success: false, - error: 'Zu viele Login-Versuche. Bitte in 15 Minuten erneut versuchen.', + error: 'Zu viele Login-Versuche für diese Kombination aus Account und IP. Bitte in 15 Minuten erneut versuchen.', }, - // Erfolgreiche Logins zählen nicht gegen das Limit skipSuccessfulRequests: true, + keyGenerator: (req): string => { + const email = (req.body?.email || '').toString().trim().toLowerCase(); + const ip = req.ip || 'unknown'; + return email ? `${ip}|${email}` : `${ip}|`; + }, handler: (req, res, _next, options) => { onLimitReached('login', 'HIGH')(req, res); res.status(options.statusCode).json(options.message); diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 03de2948..1aedf2dc 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -5,6 +5,11 @@ import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLi const router = Router(); +// loginRateLimiter sperrt pro (IP + Email)-Tuple. Damit kann sich +// `nina` von derselben IP einloggen, auch wenn `max` dort gerade +// 10x vergeigt hat – und umgekehrt darf `max` von einer anderen IP +// auch dann noch versuchen, wenn IP-A gerade sein Bucket verbrannt +// hat (Pentest 2026-05-18 Szenario). router.post('/login', loginRateLimiter, authController.login); router.post('/customer-login', loginRateLimiter, authController.customerLogin); router.post('/refresh', authController.refresh); diff --git a/docs/todo.md b/docs/todo.md index fd5ae7c5..5f3eed49 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,36 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🛡️ Login-Rate-Limit jetzt pro (IP + Email)-Tupel** + - Vorher reine IP-basierte Sperre, was zwei Schwächen hatte: + a) Familie hinter NAT: Max vertippt sich → Nina kommt nicht rein + b) Angreifer wechselt Proxy → wieder 10 freie Versuche pro + Account, dieselbe IP-only-Sperre umgangen. + - Eine reine Email-Sperre wurde verworfen wegen Account-Lockout- + DoS (jeder kann fremde Accounts sperren) + denselben Shared-IP- + Problem. + - **Lösung**: Bucket-Key ist `${ip}|${email-lowercase}`. Damit: + * Max von IP-A 10x vergeigt → (IP-A, max) gesperrt + * Nina von IP-A → eigenes Bucket (IP-A, nina), unbetroffen + * Admin von IP-A mit richtigem PW → erfolgreicher Login + * Max von IP-B → eigenes Bucket (IP-B, max), darf wieder + - Implementation: `loginRateLimiter.keyGenerator = ${ip}|${email}` + in `middleware/rateLimit.ts`; nur ein Limiter, kein zusätzlicher + Email-only. + - Admin-UI: Listing zeigt Tupel (IP, Email), Reset schickt + beides mit, Audit-Log resourceId = `${ip}|${email}`. + - **Live-verifiziert** (4 Schritte): + 11x falsch max → 429, Nina/Admin von gleicher IP → durch, + max bleibt gesperrt, Reset → max wieder 401. + +- [x] **🚨 PUT /customers/:id/portal mit `password` im Body → 400** + - Endpoint nahm `password` silent entgegen, ignorierte es, gab + aber HTTP 200 zurück → Client glaubte fälschlich, das Passwort + sei gesetzt. Fix: explizite Body-Validierung – `password`, + `portalPassword`, `portalPasswordHash`, `portalPasswordEncrypted` + sind verbotene Felder, HTTP 400 mit Hinweis auf den dedizierten + `POST /portal/password`-Endpoint. + - [x] **🚨 Pentest Runde 17 – JWT-TTL + Pentest-Marker-Detection** - **21.1 Access-Token 7 Tage**: Bug-Quelle waren die `.env`-Files, die noch die alte Konvention vor der Refresh-Token-Trennung diff --git a/frontend/src/pages/settings/RateLimits.tsx b/frontend/src/pages/settings/RateLimits.tsx index 5c9c3906..e64491de 100644 --- a/frontend/src/pages/settings/RateLimits.tsx +++ b/frontend/src/pages/settings/RateLimits.tsx @@ -31,9 +31,11 @@ export default function RateLimits() { }); const resetMutation = useMutation({ - mutationFn: (ip: string) => rateLimitApi.reset(ip), - onSuccess: (_, ip) => { - toast.success(`Rate-Limit für ${ip} freigegeben`); + mutationFn: (e: ActiveRateLimit) => + rateLimitApi.reset({ ipAddress: e.ipAddress, email: e.email || undefined }), + onSuccess: (_, e) => { + const label = e.email ? `${e.ipAddress} + ${e.email}` : e.ipAddress; + toast.success(`Rate-Limit für ${label} freigegeben`); qc.invalidateQueries({ queryKey: ['rate-limits-active'] }); }, onError: (err) => { @@ -58,8 +60,9 @@ export default function RateLimits() { Rate-Limit-Sperren

- IP-Adressen, die durch den Login- oder Passwort-Reset-Rate-Limiter - in den letzten 15 Minuten gesperrt wurden. + Gesperrte (IP + Account)-Paare aus den letzten 15 Minuten. + Andere Accounts von derselben IP sind nicht betroffen, und der + gesperrte Account kann sich weiter von einer anderen IP einloggen.

@@ -83,7 +86,7 @@ export default function RateLimits() { IP-Adresse - Letzter Versuch (E-Mail) + Account (E-Mail) Limiter Hits Zuletzt @@ -92,13 +95,13 @@ export default function RateLimits() { {entries.map((e) => ( - + {e.ipAddress} - {e.lastEmail ? ( - {e.lastEmail} + {e.email ? ( + {e.email} ) : ( - + — (kein Account) )} @@ -117,7 +120,7 @@ export default function RateLimits() {