/** * Simple in-memory per-IP rate limiter for the auth-gated endpoints * (publish, yank, unyank). The public-read endpoints (download, index, * search) are left unlimited — they're cacheable and the body-size cap * on publish already covers the main DoS vector. * * We implement this in-process rather than in Caddy because the stock * apt-installed Caddy doesn't include the community `caddy-ratelimit` * plugin. If we ever switch the caddy module to `xcaddy build` with the * plugin, we can move this upstream and delete this file. * * The limiter is a fixed window counter: each IP gets a fresh allowance * every `windowMs`. No burst smoothing, which is fine for the tiny * traffic a registry actually sees — we just want to keep a bad actor * from flooding the disk via repeated publish attempts. */ interface Bucket { count: number; /** Epoch ms when this bucket resets to 0 and `windowMs` restarts. */ resetAt: number; } export interface RateLimiter { /** * Record one hit from `ip`. Returns {ok: true, remaining} if under the * limit; {ok: false, retryAfterSec} if the window is exhausted. */ take(ip: string): { ok: true; remaining: number } | { ok: false; retryAfterSec: number }; } export function createRateLimiter(opts: { /** Max events per IP per window. */ max: number; /** Window length in ms. */ windowMs: number; /** Inject a clock for tests; defaults to Date.now. */ now?: () => number; }): RateLimiter { const buckets = new Map(); const now = opts.now ?? (() => Date.now()); // Opportunistic cleanup: if the map grows past a size threshold, drop // expired entries. Keeps memory bounded even under churny attackers // hitting from rotating IPs. const CLEANUP_AT = 1000; return { take(ip: string) { const t = now(); let bucket = buckets.get(ip); if (!bucket || t >= bucket.resetAt) { bucket = { count: 0, resetAt: t + opts.windowMs }; buckets.set(ip, bucket); } bucket.count += 1; if (bucket.count > opts.max) { return { ok: false, retryAfterSec: Math.max(1, Math.ceil((bucket.resetAt - t) / 1000)) }; } if (buckets.size > CLEANUP_AT) { for (const [k, v] of buckets) if (t >= v.resetAt) buckets.delete(k); } return { ok: true, remaining: opts.max - bucket.count }; }, }; } /** * Extract the caller's IP for rate-limit keying. Prefers the first entry * of X-Forwarded-For (trusted-proxy assumption — we sit behind Caddy); * falls back to the raw socket peer via Bun.Server.requestIP. */ export function clientIp( req: Request, server: { requestIP(r: Request): { address: string } | null }, ): string { const xff = req.headers.get('x-forwarded-for'); if (xff) { const first = xff.split(',')[0]?.trim(); if (first) return first; } return server.requestIP(req)?.address ?? 'unknown'; }