/** * Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset). * Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe. * * Wenn ein Limit überschritten wird, emit() wir zusätzlich ein * SecurityEvent (RATE_LIMIT_HIT) – damit der Monitoring-View und das * Alert-System sehen, wenn jemand auf die Tür hämmert. */ import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js'; function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') { return (req: any, _res: any) => { const ctx = contextFromRequest(req); emitSecurityEvent({ type: 'RATE_LIMIT_HIT', severity, message: `Rate-Limit überschritten: ${label}`, ipAddress: ctx.ipAddress, userEmail: req.body?.email, endpoint: ctx.endpoint, details: { limiter: label }, }); }; } /** * 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, limit: 10, standardHeaders: 'draft-7', legacyHeaders: false, message: { success: false, error: 'Zu viele Login-Versuche für diese Kombination aus Account und IP. Bitte in 15 Minuten erneut versuchen.', }, skipSuccessfulRequests: true, keyGenerator: (req): string => { const email = (req.body?.email || '').toString().trim().toLowerCase(); // IPv6-Härtung: ipKeyGenerator normalisiert IPv6 auf das Subnetz // (Default /56), damit ein Angreifer nicht durch Rotation innerhalb // seines zugeteilten IPv6-Blocks das Per-IP-Limit umgeht. IPv4 bleibt // unverändert. Die Limiter OHNE eigenen keyGenerator (Passwort-Reset, // Consent) machen das schon über den Library-Default – hier holen wir // die Custom-keyGenerator-Limiter auf denselben Stand. const ip = ipKeyGenerator(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); }, }); /** * Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP. * Verhindert Mail-Flut und gezielte Brute-Force über Reset-Links. */ export const passwordResetRateLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 Stunde limit: 5, standardHeaders: 'draft-7', legacyHeaders: false, message: { success: false, error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.', }, handler: (req, res, _next, options) => { onLimitReached('password-reset', 'MEDIUM')(req, res); res.status(options.statusCode).json(options.message); }, }); /** * Staff-Password-Set-Limiter (Pentest 48.3, 2026-06-01): * POST /api/users/:id/password verlangt seit 47.3 die Eingabe des eigenen * Admin-Passworts (`currentPassword`). Ohne Throttle könnte ein Angreifer * mit gestohlenem JWT die 25-Zeichen-Passwort-Policy zwar nicht erraten, * aber kürzere/typische Admin-Passwörter (z.B. Stagings, kompromittierte * Setups) per Brute-Force durchprobieren – und damit den Re-Auth-Fix * komplett aushebeln. * * Bucket: (IP, target-user-id). Damit walked ein Angreifer pro Opfer * langsam und kann nicht mit einem stolen-token gegen alle Staff-User * parallel anrennen. `skipSuccessfulRequests: true`, weil legitime * Passwort-Resets nicht den Counter füllen sollen. */ export const staffPasswordReAuthLimiter = rateLimit({ windowMs: 10 * 60 * 1000, // 10 Minuten limit: 5, standardHeaders: 'draft-7', legacyHeaders: false, message: { success: false, error: 'Zu viele fehlgeschlagene Passwort-Set-Versuche. Bitte in 10 Minuten erneut versuchen.', }, skipSuccessfulRequests: true, keyGenerator: (req): string => { // IPv6-Härtung wie beim Login-Limiter (siehe dort). const ip = ipKeyGenerator(req.ip || 'unknown'); const targetUserId = (req.params?.id ?? '').toString(); return `${ip}|staff-pw|${targetUserId}`; }, handler: (req, res, _next, options) => { onLimitReached('staff-password-set', 'HIGH')(req, res); res.status(options.statusCode).json(options.message); }, }); /** * Public-Consent-Endpoints (/api/public/consent/:hash[/grant|/pdf]) sind * unauthenticated. Der hash ist 128-bit-UUID → kein Brute-Force-Risk, * aber DoS-Vektor: ohne Limit könnte ein Angreifer endlos POSTen und * den Service durch Audit-Log-Spam + Mail-Versand belasten. * (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht * für legitime Kunden weit aus. */ /** * Globaler Backstop-Limiter für ALLE /api-Requests (Pentest R148). * * Hintergrund: Es gab bislang KEINEN generellen /api-Limiter – nur die * dedizierten oben (Login, Passwort-Reset, Staff-Re-Auth, Consent). Damit war * jeder authentifizierte Endpoint gegen Enumeration/Scripted-Abuse/DoS * ungedrosselt. Dieser Limiter ist eine großzügige Obergrenze, KEIN Ersatz für * die feineren Limiter (die feuern früher und bleiben aktiv). * * Key = NUR die (IPv6-/56-normalisierte) IP. Bewusst NICHT (IP+User): den * User-Claim könnten wir hier nur unverifiziert aus dem Token lesen (die volle * `authenticate`-Prüfung inkl. DB läuft erst pro Route). Ein Angreifer könnte * dann mit gefälschten userId-Claims beliebig frische Buckets erzeugen und den * Backstop umgehen. Per-Account-Präzision liefern ohnehin die Login-Limiter. * * Limit per Env `API_RATE_LIMIT_PER_MIN` (Default 1200/min ≈ 20/s pro IP) – * für legitime Nutzung (auch mehrere Nutzer hinter NAT) weit ausreichend, * bremst aber Flooding massiv. Healthcheck (`/api/health`) ist ausgenommen. * * KEIN SecurityEvent pro geblocktem Request: Bei einem Flood würde das den * Security-/Audit-Store selbst zumüllen (Amplification). Die 429 stehen im * Access-Log; die feineren Limiter melden weiterhin an das Monitoring. */ const API_RATE_LIMIT_PER_MIN = Math.max( parseInt(process.env.API_RATE_LIMIT_PER_MIN || '', 10) || 1200, 60, ); export const apiBackstopRateLimiter = rateLimit({ windowMs: 60 * 1000, limit: API_RATE_LIMIT_PER_MIN, standardHeaders: 'draft-7', legacyHeaders: false, message: { success: false, error: 'Zu viele Anfragen in kurzer Zeit. Bitte einen Moment warten.', }, keyGenerator: (req): string => ipKeyGenerator(req.ip || 'unknown'), skip: (req) => req.path === '/health', }); export const publicConsentRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 30, standardHeaders: 'draft-7', legacyHeaders: false, message: { success: false, error: 'Zu viele Anfragen. Bitte in 15 Minuten erneut versuchen.', }, handler: (req, res, _next, options) => { onLimitReached('public-consent', 'MEDIUM')(req, res); res.status(options.statusCode).json(options.message); }, });