Files
website/src/lib/server/worldtodo/limit.ts

67 lines
2.4 KiB
TypeScript

import { createHmac, randomBytes } from 'node:crypto';
// Rate limiting for public change requests: 5 per hour and 20 per day per client.
// The client key is an HMAC of the IP address with a random salt that lives only in this
// process's memory and rotates every UTC day, so a key can't be linked to an IP (or to
// yesterday's key) and nothing is ever written to Postgres or logs. Counters are per process
// and reset on restart, like the site's other limiters.
export const PER_HOUR = 5;
export const PER_DAY = 20;
const HOUR = 3_600_000;
const DAY = 86_400_000;
const MAX_KEYS = 50_000;
type State = { day: string; salt: Buffer; hits: Map<string, number[]> };
export function createLimiter(now: () => number = Date.now) {
let state: State | null = null;
const current = () => {
const day = new Date(now()).toISOString().slice(0, 10);
if (!state || state.day !== day) state = { day, salt: randomBytes(32), hits: new Map() };
return state;
};
const keyOf = (ip: string) =>
createHmac('sha256', current().salt).update(ip).digest('base64url').slice(0, 22);
return {
/** The salted hash for an IP (exposed for tests; never stored). */
keyOf,
/**
* Count one attempt. Returns null when allowed, or the seconds to wait (Retry-After).
* Blocked attempts are not counted.
*/
hit(ip: string): number | null {
const s = current();
const key = keyOf(ip);
const t = now();
const list = (s.hits.get(key) ?? []).filter((x) => t - x < DAY);
const lastHour = list.filter((x) => t - x < HOUR);
if (lastHour.length >= PER_HOUR)
return Math.max(1, Math.ceil((lastHour[0] + HOUR - t) / 1000));
if (list.length >= PER_DAY) return Math.max(1, Math.ceil((list[0] + DAY - t) / 1000));
list.push(t);
if (!s.hits.has(key) && s.hits.size >= MAX_KEYS) s.hits.delete(s.hits.keys().next().value!);
s.hits.set(key, list);
return null;
},
/** Number of tracked keys (tests). */
size: () => current().hits.size
};
}
export const limiter = createLimiter();
/** The client IP behind Cloudflare/Caddy, same order as server.js and the listening stream. */
export function clientIp(request: Request, getClientAddress: () => string): string {
const h = request.headers;
const forwarded =
h.get('cf-connecting-ip') ?? h.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '';
if (forwarded) return forwarded;
try {
return getClientAddress();
} catch {
return 'unknown';
}
}