Files
craftvia/src/server/rate-limit.ts
T
msolarczekandClaude Opus 5 ef2f3f16c6 Härtung: Login-Drosselung je IP/Konto, Mail-Anhangfehler ohne Retry, doppeltes Upload-Audit
- 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>
2026-09-14 18:59:47 +02:00

114 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}