/** * Minimal in-memory fixed-window rate limiter for a single Node process * (this app always runs as one process - see server.ts). Good enough to * blunt abuse of the email-verification resend endpoint without adding an * external store; resets on deploy/restart, which is an acceptable * trade-off for this use case. */ const buckets = new Map(); // Opportunistic cleanup so long-running processes don't accumulate an // unbounded number of stale keys (one per distinct IP/email ever seen). const MAX_TRACKED_KEYS = 5000; function sweepExpired(now: number) { for (const [key, bucket] of buckets) { if (bucket.resetAt <= now) buckets.delete(key); } } export function checkRateLimit(key: string, opts: { max: number; windowMs: number }): boolean { const now = Date.now(); if (buckets.size >= MAX_TRACKED_KEYS) sweepExpired(now); const bucket = buckets.get(key); if (!bucket || bucket.resetAt <= now) { if (!bucket && buckets.size >= MAX_TRACKED_KEYS) return false; buckets.set(key, { count: 1, resetAt: now + opts.windowMs }); return true; } if (bucket.count >= opts.max) return false; bucket.count += 1; return true; } export function getClientIp(request: { headers: Headers }): string { // Deployment is always behind exactly one trusted reverse proxy (see // DEPLOYMENT.md - Nginx Proxy Manager; the app port isn't published // directly to the host). nginx's default `$proxy_add_x_forwarded_for` // *appends* the real client IP to any X-Forwarded-For it already // received rather than replacing it, so a directly-connecting client // could prepend arbitrary spoofed entries of their own. Only the // right-most (last) entry - the one the trusted proxy itself appended - // is trustworthy; taking the first entry would let a client bypass this // rate limit entirely by sending a fake X-Forwarded-For header. const forwarded = request.headers.get("x-forwarded-for"); if (forwarded) { const parts = forwarded.split(","); return parts[parts.length - 1]!.trim(); } return "unknown"; }