- rate-limit: neuer Scope login (20/15 min, LOGIN_RATE_LIMIT_PER_15_MIN) je IP und je Konto; geprüft in verifyIdentityPassword (Login-Seite + Credentials-Provider), gedrosselt verhält sich wie Fehlanmeldung (generisch, konstante Laufzeit) – L10a - mail/worker: MailAttachmentError wie MailNotConfiguredError unrecoverable (fremder Mandant/Prüfsumme/Größe ändern sich nicht durch Warten) – L11 - field/uploads: Dokument-Audit nur noch in storeFile (vorher doppelt) – L10a - Test test-login-rate-limit Gate 65/65 grün; komplette Suite mit RLS_ENFORCED=true 65/65 grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
114 lines
4.6 KiB
TypeScript
114 lines
4.6 KiB
TypeScript
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<string, Bucket>();
|
||
|
||
/** 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<string, RateLimitRule>;
|
||
|
||
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();
|
||
}
|