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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhY1QhDXGWfyhrxaJvfpNt
This commit is contained in:
parent
60af2e1621
commit
a26cc3f8f2
|
|
@ -8,23 +8,44 @@ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
|||
const RATE_LIMIT_MAX = 3;
|
||||
const requestLog = new Map<string, number[]>();
|
||||
|
||||
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" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue