67 lines
2.4 KiB
TypeScript
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';
|
|
}
|
|
}
|