109 lines
3.1 KiB
TypeScript
109 lines
3.1 KiB
TypeScript
/**
|
|
* 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<string, Bucket>();
|
|
|
|
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';
|
|
} |