From a26cc3f8f29a61000ee6f30716be206bdac82f7e Mon Sep 17 00:00:00 2001 From: MBO-Tech-IT Date: Tue, 21 Jul 2026 14:21:34 +0200 Subject: [PATCH] fix: use rightmost X-Forwarded-For entry and add per-email rate-limit dimension Fixes an XFF-spoofing bypass of the flyer rate limiter (leftmost entry is attacker-controlled, not proxy-verified) and adds a second rate-limit dimension keyed on the submitted email so spamming one address still gets blocked even if IP-based limiting is ever defeated. Also bounds the in-memory rate-limit Map via a call-count-triggered sweep. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LhY1QhDXGWfyhrxaJvfpNt --- app/api/familyguard-flyer/route.ts | 39 +++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/app/api/familyguard-flyer/route.ts b/app/api/familyguard-flyer/route.ts index af767ec..3035531 100644 --- a/app/api/familyguard-flyer/route.ts +++ b/app/api/familyguard-flyer/route.ts @@ -8,23 +8,44 @@ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; const RATE_LIMIT_MAX = 3; const requestLog = new Map(); -function isRateLimited(ip: string): boolean { +let cleanupCounter = 0; +const CLEANUP_INTERVAL = 500; + +function isRateLimited(key: string): boolean { const now = Date.now(); - const timestamps = (requestLog.get(ip) ?? []).filter( + + cleanupCounter++; + if (cleanupCounter >= CLEANUP_INTERVAL) { + cleanupCounter = 0; + for (const [k, timestamps] of requestLog) { + const fresh = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS); + if (fresh.length === 0) requestLog.delete(k); + else requestLog.set(k, fresh); + } + } + + const timestamps = (requestLog.get(key) ?? []).filter( (t) => now - t < RATE_LIMIT_WINDOW_MS ); if (timestamps.length >= RATE_LIMIT_MAX) { - requestLog.set(ip, timestamps); + requestLog.set(key, timestamps); return true; } timestamps.push(now); - requestLog.set(ip, timestamps); + requestLog.set(key, timestamps); return false; } +// Nimmt an, dass genau ein Reverse-Proxy vorgeschaltet ist, der die beobachtete +// Peer-IP anhängt (nicht ersetzt), und dass die App nie unter Umgehung dieses +// Proxys direkt erreichbar ist — falls das nicht zutrifft, fällt der Limiter auf +// den gemeinsamen "unknown"-Bucket zurück, wird aber nicht umgehbar. function getClientIp(request: Request): string { const forwarded = request.headers.get("x-forwarded-for"); - if (forwarded) return forwarded.split(",")[0].trim(); + if (forwarded) { + const parts = forwarded.split(",").map((p) => p.trim()).filter(Boolean); + if (parts.length > 0) return parts[parts.length - 1]; + } return "unknown"; } @@ -33,7 +54,7 @@ function isValidEmail(email: string): boolean { } export async function POST(request: Request) { - if (isRateLimited(getClientIp(request))) { + if (isRateLimited(`ip:${getClientIp(request)}`)) { return NextResponse.json( { ok: false, error: "Zu viele Anfragen. Bitte versuchen Sie es später erneut." }, { status: 429 } @@ -54,6 +75,12 @@ export async function POST(request: Request) { { status: 400 } ); } + if (isRateLimited(`email:${email}`)) { + return NextResponse.json( + { ok: false, error: "Zu viele Anfragen. Bitte versuchen Sie es später erneut." }, + { status: 429 } + ); + } if (body.dsgvoEinwilligung !== true) { return NextResponse.json( { ok: false, error: "Bitte stimmen Sie der Datenverarbeitung zu" },