Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
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 };
|
||||
|
||||
/** 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 },
|
||||
/** 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 },
|
||||
} 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();
|
||||
}
|
||||
Reference in New Issue
Block a user