Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
109
lib/security/rate-limit.ts
Normal file
109
lib/security/rate-limit.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
Reference in New Issue
Block a user