import { createHash } from "node:crypto"; /** * SEC2 — Rate-Limiting für sicherheitskritische Endpunkte. * * Zweck: Reset-Anfrage, Reset-Einlösung und Alt-Passwort-Prüfung dürfen nicht * beliebig oft versucht werden — je **IP** und je **Konto** getrennt gezählt, * damit weder ein einzelner Angreifer viele Konten noch viele Quellen ein Konto * durchprobieren können. * * Ergänzt den bestehenden Konto-Lockout (F-05) am Login; SEC5 bündelt später die * globalen Limits. Die Schnittstelle ist bewusst so geschnitten, dass SEC5 sie * übernehmen kann, ohne die Aufrufer anzufassen. * * **Speicher:** Fenster-Zähler im Prozessspeicher. Das ist bei mehreren * App-Instanzen pro Instanz gezählt — bewusst in Kauf genommen, weil der Limiter * hier nur eine erste Bremse ist und die eigentlichen Sicherheitsgarantien * (single-use-Tokens, Enumeration-Neutralität, Konto-Lockout) nicht davon * abhängen. Ein geteilter Redis-Zähler gehört zu SEC5. * * Schlüssel werden gehasht abgelegt: der Speicher enthält damit weder * Klartext-Adressen noch IPs. */ type Bucket = { count: number; resetAt: number }; const buckets = new Map(); /** Aufräumen abgelaufener Fenster — verhindert unbegrenztes Wachstum. */ function sweep(now: number): void { if (buckets.size < 5_000) return; for (const [key, bucket] of buckets) { if (bucket.resetAt <= now) buckets.delete(key); } } function keyOf(scope: string, identifier: string): string { return `${scope}:${createHash("sha256").update(identifier.toLowerCase()).digest("hex").slice(0, 32)}`; } export type RateLimitRule = { limit: number; windowMs: number }; /** Positive integer from the environment, otherwise the default (read once at module load). */ function perMinute(name: string, fallback: number): number { const v = Number(process.env[name]); return Number.isInteger(v) && v > 0 ? v : fallback; } /** Voreinstellungen der sicherheitskritischen Aktionen (SEC2 §6). */ export const RATE_LIMITS = { /** Reset-Anfrage: 5 pro Stunde je IP und je Konto. */ passwordResetRequest: { limit: 5, windowMs: 60 * 60_000 }, /** Login (Passwortprüfung): je IP und je Konto – bremst Password-Spraying über viele Konten. */ login: { limit: perMinute("LOGIN_RATE_LIMIT_PER_15_MIN", 20), windowMs: 15 * 60_000 }, /** Reset-Einlösung: begrenzt das Durchprobieren manipulierter Links. */ passwordResetRedeem: { limit: 10, windowMs: 15 * 60_000 }, /** Alt-Passwort-Prüfung bei Selbständerung. */ passwordVerify: { limit: 10, windowMs: 15 * 60_000 }, /** Anforderung einer E-Mail-Änderung. */ emailChangeRequest: { limit: 5, windowMs: 60 * 60_000 }, /** L10b: /api/v1/** je Nutzer (Integrationen, Backoffice-Clients). */ api: { limit: perMinute("API_RATE_LIMIT_PER_MINUTE", 300), windowMs: 60_000 }, /** L10b: Einsatz-/Sync-Endpunkte (/sync, /uploads, /field/**) je Nutzer — großzügig (Outbox, Vorab-Download). */ apiField: { limit: perMinute("API_FIELD_RATE_LIMIT_PER_MINUTE", 1200), windowMs: 60_000 }, } as const satisfies Record; export type RateLimitScope = keyof typeof RATE_LIMITS; export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number }; /** * Zählt einen Versuch und meldet, ob er noch erlaubt ist. * `identifier` ist die IP **oder** die Konto-Kennung — Aufrufer prüfen beide. */ export function consumeRateLimit(scope: RateLimitScope, identifier: string): RateLimitResult { const rule = RATE_LIMITS[scope]; const now = Date.now(); sweep(now); const key = keyOf(scope, identifier); const bucket = buckets.get(key); if (!bucket || bucket.resetAt <= now) { buckets.set(key, { count: 1, resetAt: now + rule.windowMs }); return { allowed: true, retryAfterSeconds: 0 }; } bucket.count++; if (bucket.count > rule.limit) { return { allowed: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) }; } return { allowed: true, retryAfterSeconds: 0 }; } /** * Prüft IP **und** Konto in einem Aufruf. Beide Zähler werden erhöht, damit ein * Angreifer weder über wechselnde Konten noch über wechselnde IPs entkommt. */ export function checkRateLimit( scope: RateLimitScope, parts: { ip?: string | null; account?: string | null }, ): RateLimitResult { const results: RateLimitResult[] = []; if (parts.ip) results.push(consumeRateLimit(scope, `ip:${parts.ip}`)); if (parts.account) results.push(consumeRateLimit(scope, `acct:${parts.account}`)); const blocked = results.find((r) => !r.allowed); return blocked ?? { allowed: true, retryAfterSeconds: 0 }; } /** Nur für Tests. */ export function resetRateLimits(): void { buckets.clear(); }