52 lines
2.1 KiB
TypeScript
52 lines
2.1 KiB
TypeScript
/**
|
|
* 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<string, { count: number; resetAt: number }>();
|
|
|
|
// 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";
|
|
}
|