import { durationToSeconds } from './common.ts' import type { FastifyReply, FastifyRequest } from 'fastify' import type { RateLimitPolicy } from '../models/rateLimits.ts' /** * Defaults for the limit on the authentication endpoints, used until an administrator saves their own * and whenever a stored value is missing or unusable. Ten attempts in five minutes is far more than a * person signing in needs and far less than guessing a password takes. */ const AUTH_DEFAULTS: RateLimitPolicy = { max: 10, windowSeconds: 300, banSeconds: 900 } /** * The limit on asking for a page to be rendered. * * Not configurable, unlike the authentication limit above: what this protects is the host rather than * a secret, and no deployment has a reason to raise it. How many browsers run at once is settled by * the render queue rather than here — this only keeps one client from filling that queue faster than * anything could drain it. Ten in five minutes is far more than re-rendering a stale page takes. */ const RENDER_LIMIT: RateLimitPolicy = { max: 10, windowSeconds: 300, banSeconds: 300 } /** * The configured policy. * * Every field falls back on its own, so one unusable value leaves the rest of the limit standing * rather than turning it off — which is the failure mode worth avoiding here. The two durations are * stored as an operator wrote them (`5m`, `15m`, `1d`), the way the JWT settings beside them are. */ function authPolicy(): RateLimitPolicy { const security = WIKI.config.security ?? {} const max = Number(security.authRateLimitMax) return { max: Number.isFinite(max) && max > 0 ? Math.floor(max) : AUTH_DEFAULTS.max, windowSeconds: durationToSeconds(security.authRateLimitWindow, AUTH_DEFAULTS.windowSeconds), banSeconds: durationToSeconds(security.authRateLimitBan, AUTH_DEFAULTS.banSeconds) } } /** * Refuse an attempt at an authentication endpoint once a client has made too many. * * Written as a per-route `onRequest` hook — `{ onRequest: limitAuthAttempts, schema: … }` — so that it * runs before the body is even parsed, and so that the routes it guards say so where they are declared * rather than in a list somewhere else. The endpoints that carry it are the ones where the request * IS the guess: signing in, answering a second factor, changing a password from the login screen, * a passkey ceremony, and unlocking a page. * * One counter per client address, shared by all of them: an attacker working through passwords on two * of these endpoints is one attacker, and splitting the count per endpoint would let them have the * limit twice over. `req.ip` is what the client is identified by, which behind a proxy means the * `trustProxy` security setting has to be on for this to see anything but the proxy. * * Attempts are counted whether or not they succeed. A limit on failures only would leave the endpoint * open to being hammered with valid credentials, and the numbers are set for a person signing in, who * does not come close to them. */ export async function limitAuthAttempts(req: FastifyRequest, reply: FastifyReply): Promise { if (WIKI.config.security?.authRateLimitEnabled === false) { return } const verdict = await WIKI.models.rateLimits.consume(`auth:${req.ip}`, authPolicy()) if (verdict.allowed) { return } WIKI.models.flags.authDebug( `Rate limit: refused ${req.method} ${req.url} from ${req.ip}, ${verdict.retryAfter}s left of its ban.` ) /* 429 rather than 403, and with `Retry-After`: this is not a refusal to serve the client, it is the same answer as before with a time on it — which is what a legitimate user locked out by a shared address needs to be told. */ reply.header('Retry-After', String(verdict.retryAfter)) return reply.tooManyRequests( `Too many attempts. Try again in ${Math.ceil(verdict.retryAfter / 60)} minute(s).` ) } /** * Refuse a request to render a page once a client has made too many. * * Written as a per-route `preHandler` hook — `{ preHandler: limitRenders, schema: … }` — so that the * route it guards says so where it is declared. It runs after the session is decoded, which is what * lets it count per user rather than per address: the endpoint needs a session, and unlike a password * guess the cost is the caller's own, so an office behind a single address should not share a limit the * way password guessers are made to. * * `manage:system` is exempt, as it is everywhere. Re-rendering every page after a markdown config * change is an operator's job, and a root admin who wants the server busy has easier ways. */ export async function limitRenders(req: FastifyRequest, reply: FastifyReply): Promise { if (req.session?.permissions?.includes('manage:system')) { return } const verdict = await WIKI.models.rateLimits.consume( `render:${req.session?.user?.id ?? req.ip}`, RENDER_LIMIT ) if (verdict.allowed) { return } WIKI.logger.debug( `Rate limit: refused ${req.method} ${req.url} from ${req.ip}, ${verdict.retryAfter}s left of its ban.` ) reply.header('Retry-After', String(verdict.retryAfter)) return reply.tooManyRequests( `Too many render requests. Try again in ${Math.ceil(verdict.retryAfter / 60)} minute(s).` ) }