GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — feat(server): add rate limiting middleware by JosunLP · Pull Request #240 · bQuery/bQuery Conversation
Collaborator
Summary
MotivationCloses #223. No server-side throttling primitive existed, so any public endpoint was unprotected against brute force by default — including the login route in the docs' own Changes included
Design, as the issue specified
Plus Validation
35 new tests, weighted toward the boundary: the Checklist
Notes for reviewersThe one place I did not follow the issueThe issue says "default Defaulting to it would ship a limiter that an attacker bypasses by sending a different header per request — worse than no limiter, because the app looks protected. So:
The better fix is plumbing a real Two smaller notes
🤖 Generated with Claude Code https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz Generated by Claude Code
Sorry, something went wrong. Uh oh!
There was an error while loading. Please reload this page. All reactionsNo 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, which now shows the limit.
(`createRequestQueue` in `reactive` is the client-side mirror image: it
limits outgoing parallel requests, not incoming ones.)
`rateLimit({ window, max, keyBy, store, ... })` counts requests per key in
a fixed window. Counters live in a `SessionStore`, the same abstraction
sessions use, so a Redis-backed store plugs in the same way and the limit
holds across processes. Responses carry `RateLimit-Limit`, `-Remaining`
and `-Reset`; a rejected request gets 429 with `Retry-After`.
`keyBy` is required rather than defaulting to the client address, which
is a deliberate departure from the issue. `ServerContext` exposes no peer
address, so the only available default would be `X-Forwarded-For` — a
header the client sets unless a proxy you control overwrites it. Defaulting
to it would ship a limiter an attacker bypasses by varying the header per
request: worse than none, because it looks like protection. `trustProxy:
true` opts into that behaviour explicitly, and the constructor throws with
a message naming both options when neither is given.
The over-limit test compares the raw count rather than `remaining`, which
is clamped at zero and so cannot tell the max-th request from the one
after it — the boundary tests cover both.
Closes #223
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz
|
There was an error while loading. Please reload this page.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Code review of the rate-limiting middleware. The structure, the SessionStore reuse and the count-vs-remaining boundary reasoning are all sound, and the analysis in the PR description about not defaulting keyBy to a spoofable header is the right call. But three findings mean the limiter does not hold in the cases it was added for — each reproduced against this branch:
ctx.session?.$id is null until a session is written, so a cookie-less brute force keys to null and every request is skipped. 20/20 attempts reach the handler. This is the exact scenario the PR sets out to fix, in the guide, the cookbook and the JSDoc.consume() is a non-atomic read-modify-write, so concurrent requests on one key are undercounted: 10 parallel requests with max: 2 all returned 200, with the default synchronous memoryStore. Parallel connections are the normal shape of the attack being defended against.trustProxy trusts the leftmost X-Forwarded-For ahead of CF-Connecting-IP/True-Client-IP, but Cloudflare and nginx's standard config append to XFF rather than overwrite, so the leftmost entry stays client-controlled and the opt-in path is bypassed by rotating the header — 5/5 requests passed with max: 1.Plus: the default memoryStore() is unbounded and never sweeps expired keys (memory exhaustion via key rotation), skipSuccessfulRequests refunds 3xx and so disables the limit on a redirect-on-failure login form, the refund does not check that the window is still the one it counted in, and headers: false silently drops Retry-After from the 429.
Detail and repros in the inline comments.
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| rateLimit({ | ||
| window: 15 * 60_000, | ||
| max: 5, | ||
| keyBy: (ctx) => ctx.session?.$id ?? null, |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
The headline example applies no limit at all to the attack it is meant to stop.
ctx.session.$id is null until the session is first written to (createSessionController: id = initialId from the cookie, and ensureId() only runs on markDirty()). A password-guessing script sends no session cookie, so keyBy returns null on every attempt, and the middleware does if (identity === null ...) return next() — the limit is skipped entirely. Even a client that does have a session simply drops the cookie.
Verified against this branch:
app.use(session({ secret })); app.post('/login', handler, [rateLimit({ window: 15*60_000, max: 5, keyBy: (ctx) => ctx.session?.$id ?? null, skipSuccessfulRequests: true })]); // 20 cookie-less POSTs → 20 reach the handler, 0 × 429
The same shape recurs in the keyBy table below (line 404): ctx.state.user?.id ?? null is null for exactly the unauthenticated attacker, and headers.get('x-api-key') is null for a request that omits the header. Every recommended key fails open for the caller you most want to throttle.
Worth reconsidering both the docs and the API: a null key meaning "unlimited" is a footgun for a security primitive. Options are a documented fallback bucket (e.g. ctx.session?.$id ?? 'anon' plus a second discriminator), or making the skip explicit (false skips, null is an error) so it cannot happen by accident.
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| rateLimit({ | ||
| window: 15 * 60_000, | ||
| max: 5, | ||
| keyBy: (ctx) => ctx.session?.$id ?? null, |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Same problem as the guide example: ctx.session?.$id is null for a client that sends no session cookie, and a null key skips the limit. The recipe this PR describes as previously "unprotected against brute force" is still unprotected after the change — a script that just never sends a cookie gets unlimited attempts (verified: 20/20 POSTs reach handleLogin).
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| window: number, | ||
| now: number | ||
| ): Promise<RateLimitState> => { | ||
| const existing = await store.get(key); |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
consume() is a read-modify-write with an await in the middle, so concurrent requests on the same key all read the same counter and the limit does not hold.
Every in-flight request for one key that reaches await store.get(key) before any of them reaches store.set reads the same count and writes count + 1, so N concurrent requests advance the counter by 1. This is not theoretical or specific to async stores — the default memoryStore() is synchronous and it still happens, because await on a non-promise yields to the microtask queue:
app.get('/c', handler, [rateLimit({ window: 60_000, max: 2, keyBy: () => 'k' })]); await Promise.all(Array.from({ length: 10 }, () => app.handle('/c'))); // → 200,200,200,200,200,200,200,200,200,200 (expected 2 × 200, 8 × 429)
Ten simultaneous connections is what a brute-force or credential-stuffing tool does by default, so the limiter can be bypassed outright rather than merely overshot. With a Redis-backed SessionStore the window is wider still (two network round trips per request).
SessionStore has no atomic increment, so a full fix needs either an optional incr-style hook on the store or per-key serialization. Even a process-local in-flight promise chain keyed by key (chain = chain.then(() => doConsume())) would close the single-process case that the default store and the docs' own login recipe run in. Whatever the fix, this deserves a test using Promise.all — the current suite only ever awaits requests one at a time, which is why it passes.
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| import type { ServerContext, ServerHandler, ServerMiddleware } from './types'; | ||
|
|
||
| /** Headers a proxy may use to report the originating client, in priority order. */ | ||
| const FORWARDED_HEADERS = ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'true-client-ip']; |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
The header priority makes trustProxy: true spoofable in exactly the deployments it is meant for.
x-forwarded-for is checked first and the leftmost entry is taken. But the common proxies append rather than overwrite: Cloudflare appends the connecting IP to a client-supplied X-Forwarded-For (and reports the real one in CF-Connecting-IP), and nginx's standard proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for does the same. So behind such a proxy the leftmost XFF entry is attacker-controlled, and it wins over the trustworthy cf-connecting-ip / true-client-ip that this list itself anticipates:
app.get('/p', handler, [rateLimit({ window: 60_000, max: 1, trustProxy: true })]); // 5 requests, each with x-forwarded-for: 9.9.9.<i> and a fixed cf-connecting-ip: 7.7.7.7 // → 200,200,200,200,200
That is the exact failure mode the PR description argues against ("a limiter an attacker bypasses by sending a different header per request"), still present in the opt-in path. Suggest ordering the single-value, proxy-set headers first (cf-connecting-ip, true-client-ip, x-real-ip) and only then x-forwarded-for, and/or letting the caller name the header to trust (plus a hop count for XFF, taking the Nth entry from the right) rather than a fixed preference list. The guide's wording ("Behind a proxy that overwrites X-Forwarded-For") should also be sharper, since the two most widely deployed proxies do not overwrite it.
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| if (skip && (await skip(ctx))) return next(); | ||
|
|
||
| const identity = await resolveKey(ctx); | ||
| if (identity === null || identity === undefined || identity === '') return next(); |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
With trustProxy, a request carrying none of the forwarding headers is not limited at all — forwardedAddress() returns null and this line skips straight to next(). The test at tests/server-rate-limit.test.ts:248 pins that as intended ("skips ... rather than lumping them together"), but the consequence is a fail-open: any request that reaches the origin without passing through the proxy (origin port exposed alongside the CDN, a health-check path, an internal hop, a proxy misconfigured after a deploy) bypasses the limiter entirely, and the app still reports itself as protected.
For a keyBy the caller wrote, "return null to skip" is a deliberate choice; for the built-in trustProxy path the safer default is to fail closed — count header-less requests under one shared bucket (they are, by definition, not distinguishable) or reject them — with skipping available via skip if someone really wants it.
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| max, | ||
| keyBy, | ||
| trustProxy = false, | ||
| store = memoryStore(), |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
The default memoryStore() grows without bound under the traffic a limiter exists to absorb.
memoryStore() here is constructed with no maxEntries, and it only ever drops an expired entry lazily, inside get(id) for that same id (src/server/session.ts:105-115); there is no sweep. A rate-limit key is attacker-chosen (a forwarded address, an API key, a user id) and typically never seen twice, so each distinct key leaves a Map entry that is expired but never collected — one entry per unique key, for the lifetime of the process. An attacker rotating X-Forwarded-For (or x-api-key) turns the limiter into a memory-exhaustion vector, which is worse than the request flood it was added to stop.
memoryStore({ maxEntries }) already exists; defaulting to a bounded store (with the cap documented) would fix it. Note also the eviction is insertion-order, so if someone follows the guide's advice to pass the session store here, rate-limit churn will evict live sessions and log users out — worth either a warning in the guide or keeping the default counters in their own store.
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
|
||
| // Refunding a successful request keeps a burst of valid traffic from | ||
| // locking a user out, while failures still count toward the limit. | ||
| if (skipSuccessfulRequests && response.status < 400) { |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
status < 400 refunds redirects, which silently disables the limit on the most common login-form shape. The POST/redirect/GET pattern the login-form recipe is about answers a failed login with 302 /login?error=1, so every failed attempt is refunded:
app.post('/login', (ctx) => ctx.redirect('/login?error=1', 302), [ rateLimit({ window: 60_000, max: 2, keyBy: () => 'k', skipSuccessfulRequests: true }), ]); // 8 attempts → 302 × 8, never a 429
skipSuccessfulRequests is a security-relevant switch, so "success" should mean what the route means by it — consider a predicate (skipSuccessful?: (res) => boolean) or at least restricting the default to 2xx and documenting the redirect trap in the guide's options table.
Two further issues in this block:
current is the new window's counter and the decrement steals a request from it — that window then allows max + 1. Guarding with current.resetAt === state.resetAt fixes it.get + set here is the same non-atomic read-modify-write as consume(), so concurrent refunds on one key lose decrements (or, combined with a concurrent consume, lose increments).Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| // so it cannot tell the `max`-th request from the one after it. | ||
| if (state.count > max) { | ||
| const denied = onLimit ? await onLimit(ctx) : ctx.text(message, { status }); | ||
| return emitHeaders ? withRateLimitHeaders(denied, state, true) : denied; |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
headers: false also drops Retry-After from the 429, which the option does not advertise — it is documented as "Emit RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset", and the guide states unconditionally that an over-limit request "is answered 429 Too Many Requests with Retry-After". Verified: with headers: false, the 429 has retry-after: null, so a client has nothing to back off on.
Retry-After is a standard part of a 429 rather than one of the RateLimit-* informational headers; suggest always applying it to the rejection and letting headers govern only the three RateLimit-* ones.
| return emitHeaders ? withRateLimitHeaders(denied, state, true) : denied; | |
| const denied = onLimit ? await onLimit(ctx) : ctx.text(message, { status }); | |
| return withRateLimitHeaders(denied, state, true, emitHeaders); |
(with withRateLimitHeaders taking a flag for the RateLimit-* trio, or a small helper that sets only retry-after.)
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| * const loginLimit = rateLimit({ | ||
| * window: 15 * 60_000, | ||
| * max: 5, | ||
| * keyBy: (ctx) => ctx.session?.$id ?? null, |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
This @example has the same defect as the guide and cookbook copies: on a login route the caller has no session yet, $id is null until the session is first written, and a null key skips the limit — so the documented way to "protect a login route" protects nothing. Worth replacing with a key that exists for an unauthenticated request (e.g. the submitted username plus a coarse bucket, or trustProxy behind a proxy) once the null-skips-everything semantics are settled.
Generated by Claude Code
Sorry, something went wrong.
There was an error while loading. Please reload this page.
Review findings on #223, each reproduced before the fix: - The documented login recipe applied no limit at all. `ctx.session?.$id` is `null` until a session is written, and a `null` key skips the limit, so a cookie-less brute force was unthrottled: 20/20 attempts reached the handler. Guide, cookbook and JSDoc now key on the proxy-reported address (or a counted `?? 'anon'` bucket), and `keyBy` documents that `null` fails open. - `consume()` is a read-modify-write across an `await`, so 10 parallel requests with `max: 2` all returned 200 — with the synchronous default store. Calls are now serialized per key; the new test fails without it. - `trustProxy` preferred the leftmost `X-Forwarded-For`, which Cloudflare and stock nginx *append* to, leaving it client-controlled. The single-value proxy headers now win, so rotating XFF no longer bypasses the limit. - A request reaching the origin with no forwarding header was skipped entirely. It is now counted in one shared bucket: off-proxy traffic must not be unlimited while the app reports itself protected. - The default store was unbounded and swept nothing, so rotated keys grew it without limit. It is now capped, and the guide warns against passing the session store. - `skipSuccessfulRequests` refunded any status below 400, so a redirect-on-failure login form was refunded on every failed attempt. Only 2xx is refunded now, and only when the window has not rolled over — the old refund could steal budget from the next window. - `headers: false` also dropped `Retry-After` from the 429, contradicting both the option doc and the guide. It is now always sent on a rejection. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz
Resolves four conflicts, all of them the same shape: #222's `serveStatic()` and #223's `rateLimit()` land side by side in the server barrel, the `full` re-export list, the server guide's API table and the changelog. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Y2dPuUeGeK7mJvar9uLqKz
There was an error while loading. Please reload this page.
Co-authored-by: Claude Opus 5 <[email protected]> Closes #223
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.