Initial commit: Kalendartool (Next.js, Prisma, Docker)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kühn
2026-10-08 14:38:04 +02:00
commit bc49c3074e
94 changed files with 12812 additions and 0 deletions

109
lib/security/rate-limit.ts Normal file
View 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';
}