/** * Rate limiting middleware for the server module. * * No throttling primitive existed server-side, so any public endpoint was * unprotected against brute force by default — including the login route in * the docs' own `login-form` recipe (#223). (`createRequestQueue` in * `reactive` is the client-side mirror image: it limits outgoing parallel * requests, not incoming ones.) * * Counters live in a {@link SessionStore}, the same abstraction sessions use, * so a Redis-backed store plugs in here exactly as it does there — which is * what makes the limit hold across more than one process. * * @module bquery/server */ import type { SessionStore } from './session.cjs'; import type { ServerContext, ServerHandler, ServerMiddleware } from './types.cjs'; /** Options for {@link rateLimit}. */ export interface RateLimitOptions { /** Length of the counting window in milliseconds. */ window: number; /** Requests allowed per key per window. */ max: number; /** * Identity the limit is counted against — usually a client address, a * session id or a user id. Return `null` to skip the limit for a request. * * **A `null` key fails open.** Pick one that exists for the caller you most * want to throttle: `ctx.session?.$id` is `null` until a session is written, * so on a login route it skips the limit for every cookie-less request — * that is, for the brute-force script. Either key on something an * unauthenticated request always carries, or fall back to a shared bucket * (`ctx.session?.$id ?? 'anon'`) so the request is still counted. * * Required unless {@link RateLimitOptions.trustProxy} is set; see the note * on that option for why there is no safe default. */ keyBy?: (ctx: ServerContext) => string | null | Promise; /** * Derive the key from a forwarding header when no `keyBy` is given. * * Off by default, and deliberately not the default behaviour: those headers * are set by the client unless a proxy you control overwrites them. Keying * on a spoofable value gives a limiter that is trivially bypassed with a * random header per request — worse than no limiter, because it looks like * protection. * * `true` reads the **rightmost** `X-Forwarded-For` entry, and only that * header. It is the one value a proxy necessarily writes: appending and * overwriting configurations alike put the address the proxy observed at * the end of the list. * * Pass a header name to key on that header's whole value instead — for a * proxy that reports the client in one of its own: * * ```ts * rateLimit({ window: 60_000, max: 10, trustProxy: 'cf-connecting-ip' }); * ``` * * Only do that for a header your proxy **sets on every request**, thereby * overwriting whatever the client sent. A header the proxy merely passes * through is client-controlled, and keying on it reopens the bypass this * option exists to close. * * A request that arrives without the header shares a single bucket rather * than escaping the limit. */ trustProxy?: boolean | string; /** * Where counters are kept. Defaults to a process-local {@link memoryStore} * bounded at 10 000 keys, which only limits per process — pass a shared * store for more than one. * * The bound matters: rate-limit keys are attacker-chosen and usually seen * once, so an unbounded store turns the limiter into a memory-exhaustion * vector. Passing your *session* store here is not advised for the same * reason — counter churn would evict live sessions. */ store?: SessionStore; /** Prefix for store keys, so counters cannot collide with sessions. Default: `'rl:'`. */ prefix?: string; /** Emit `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. Default: `true`. */ headers?: boolean; /** Status for a rejected request. Default: `429`. */ status?: number; /** Body for the default rejection response. Default: `'Too Many Requests'`. */ message?: string; /** Skip the limit entirely for a request, without consuming budget. */ skip?: (ctx: ServerContext) => boolean | Promise; /** * Handle a rejected request yourself. Overrides `status`/`message`; the * `RateLimit-*` and `Retry-After` headers are still applied to whatever it * returns. */ onLimit?: ServerHandler; /** * Do not count requests that ended in a 2xx response. Default: `false`. * * 2xx only, deliberately. A POST/redirect/GET login form reports a *failed* * attempt with a 302, so refunding redirects would leave that form * unlimited. The refund is also skipped if the window rolled over while the * handler ran, so it cannot steal budget from the next window. */ skipSuccessfulRequests?: boolean; } /** The state of the limit for one key, as reported to a caller. */ export interface RateLimitState { /** Requests allowed per window. */ limit: number; /** * Requests made in the current window, including this one. Unlike * `remaining` this is not clamped, so it still distinguishes "exactly at the * limit" from "well past it". */ count: number; /** Requests left in the current window, never below zero. */ remaining: number; /** Epoch milliseconds at which the current window ends. */ resetAt: number; /** Seconds until the window ends, rounded up and at least one. */ resetSeconds: number; } /** * Read the originating address from a forwarding header. * * `X-Forwarded-For` — the default — is a list, and its **rightmost** entry is * taken. Any other header is a single value and is taken whole. * * One header, not a preference list over several: a proxy that sets * `CF-Connecting-IP` does not necessarily strip `X-Real-IP`, so falling back * through the others would let a client choose its own bucket by sending one * the proxy never writes. The deployment names the header its proxy controls. * * Rightmost, not leftmost, because the list grows left-to-right as it is * forwarded: the rightmost entry is the hop the closest proxy appended and is * therefore the only one that proxy vouches for. Cloudflare and nginx append * rather than overwrite, so with leftmost parsing a client that sends its own * `X-Forwarded-For` controls the value the limiter keys on and bypasses the * limit by rotating it — the exact failure {@link RateLimitOptions.keyBy} * being required is meant to prevent. RFC 9110 §7.6.1 and the MDN guidance on * security uses of `X-Forwarded-For` both say to use only what a trusted * proxy added. * * With more than one trusted proxy the rightmost entry is the inner proxy * rather than the client, so those requests share a bucket. That over-limits * rather than under-limits; a deployment that needs per-client buckets behind * a chain should pass its own `keyBy`. * * Never returns `null`: a request with no forwarding header shares * {@link UNKNOWN_FORWARDED_KEY} rather than escaping the limit, because * anything reaching the origin off-proxy would otherwise be unlimited while * the app still reports itself as protected. * @internal */ export declare const forwardedAddress: (ctx: ServerContext, header?: string) => string; /** * Advance the counter for a key and report the resulting state. * * The window is fixed, not sliding: the first request of a window sets * `resetAt`, and the counter resets wholesale when that passes. A sliding * window would need per-request timestamps in the store, which is a much * larger write amplification for a limiter whose job is to be cheap. * @internal */ export declare const consume: (store: SessionStore, key: string, max: number, window: number, now: number) => Promise; /** * Serialize async work per key, so a read-modify-write cannot interleave. * * `consume()` reads the counter, awaits, then writes it back. Concurrent * requests for one key all read the same value and the limit does not hold — * and parallel connections are the normal shape of the traffic a limiter * defends against, so this is a bypass rather than a rounding error. The * chain is process-local: it closes the single-process case the default * {@link memoryStore} runs in. A limit shared across processes still needs a * store with an atomic increment. * @internal */ export declare const serializeByKey: () => ((key: string, work: () => Promise) => Promise); /** The lock for a store, created on first use. @internal */ export declare const lockForStore: (store: SessionStore) => ReturnType; /** Apply the `RateLimit-*` headers to a response, preserving its body. @internal */ export declare const withRateLimitHeaders: (response: Response, state: RateLimitState, includeRetryAfter: boolean, includeRateLimitHeaders?: boolean) => Response; /** * Create middleware that rejects a key's requests once it exceeds `max` in * each `window`. * * @example Protect a login route * ```ts * import { createServer, rateLimit } from '@bquery/bquery/server'; * * const app = createServer(); * const loginLimit = rateLimit({ * window: 15 * 60_000, * max: 5, * // Behind a proxy, key on the address it reports. A session id would be * // `null` for the cookie-less request a brute-force script sends, and a * // `null` key skips the limit. * trustProxy: true, * skipSuccessfulRequests: true, * }); * * app.post('/login', handleLogin, [loginLimit]); * ``` * * @example Without a proxy, counting authenticated and anonymous separately * ```ts * // `?? 'anon'` matters: it keeps unauthenticated callers in one counted * // bucket instead of skipping the limit for all of them. * app.use(rateLimit({ window: 60_000, max: 100, keyBy: (ctx) => ctx.session?.$id ?? 'anon' })); * ``` */ export declare const rateLimit: (options: RateLimitOptions) => ServerMiddleware; //# sourceMappingURL=rate-limit.d.ts.map