/** * In-Memory Rate-Limiting (plan.md Abschnitt 11). * * Fester Zeitfenster-Algorithmus pro Schluessel (z. B. "login:email:x@y"). * Bewusst In-Memory: Fuer den MVP ausreichend; bei mehreren App-Instanzen * muesste ein gemeinsamer Store (z. B. Redis) verwendet werden. * * Die Anzahl getrackter Schluessel ist begrenzt (Schutz vor Memory-DoS * durch Angreifer mit vielen erfundenen E-Mails/IPs). Beim Erreichen * des Limits werden zuerst abgelaufene, danach die aeltesten Eintraege * entfernt. */ /** Maximale Anzahl gleichzeitig getrackter Schluessel. */ const MAX_TRACKED_KEYS = 10_000; interface Bucket { count: number; firstAt: number; } const buckets = new Map(); export interface RateLimitResult { allowed: boolean; /** Sekunden bis zum naechsten erlaubten Versuch (falls blockiert). */ retryAfterSeconds: number; } /** Raeumt abgelaufene bzw. ueberschuessige Eintraege auf. */ function makeRoom(now: number, windowMs: number): void { if (buckets.size < MAX_TRACKED_KEYS) { return; } // 1) Abgelaufene Eintraege entfernen (Map-Iteration erlaubt Loeschen). for (const [key, bucket] of buckets) { if (now - bucket.firstAt > windowMs) { buckets.delete(key); } } // 2) Immer noch voll: aelteste Eintraege verwerfen (Map behaelt die // Einfuege-Reihenfolge bei). while (buckets.size >= MAX_TRACKED_KEYS) { const oldest = buckets.keys().next(); if (oldest.done) { break; } buckets.delete(oldest.value); } } /** Prueft, ob der Schluessel aktuell gesperrt ist (ohne zu zaehlen). */ export function checkRateLimit( key: string, max: number, windowMs: number, ): RateLimitResult { const now = Date.now(); const bucket = buckets.get(key); if (!bucket || now - bucket.firstAt > windowMs) { return { allowed: true, retryAfterSeconds: 0 }; } if (bucket.count >= max) { return { allowed: false, retryAfterSeconds: Math.max( 1, Math.ceil((bucket.firstAt + windowMs - now) / 1000), ), }; } return { allowed: true, retryAfterSeconds: 0 }; } /** Zaehlt einen Versuch innerhalb des Zeitfensters. */ export function recordRateLimitHit(key: string, windowMs: number): void { const now = Date.now(); const bucket = buckets.get(key); if (!bucket || now - bucket.firstAt > windowMs) { makeRoom(now, windowMs); buckets.set(key, { count: 1, firstAt: now }); return; } bucket.count += 1; } /** Entfernt den Schluessel (z. B. nach erfolgreichem Login). */ export function resetRateLimit(key: string): void { buckets.delete(key); } /** * Client-IP aus den Standard-Proxy-Headern. * * Hinweis: Ohne vertrauenswuerdigen Proxy ist x-forwarded-for * clientseitig spoofbar. Limits auf Basis echter Identitaeten * (z. B. E-Mail beim Login) sind davon nicht betroffen; IP-Limits * sind eine zusaetzliche, nicht die einzige Schranke. */ export function getClientIp(request: { headers: { get(name: string): string | null }; }): string { const forwarded = request.headers.get('x-forwarded-for'); const first = forwarded?.split(',')[0]?.trim(); if (first) { return first; } return request.headers.get('x-real-ip')?.trim() || 'unbekannt'; }