fix: key the rate-limit demos on the visitor, not the proxy - #1388
Merged
Conversation
The gallery's rate-limit card promises five requests per ten seconds and did not deliver one on the deployed site. Its middleware took the default bucket key, which is the socket peer, and behind Cloudflare plus Railway that peer is an edge proxy rather than the visitor. The pool has several addresses, each carrying its own full allowance, so the effective limit was five times the pool size and refreshing never produced a 429. Nothing about it looked broken, which is why it survived. Every response still carried an X-RateLimit-Remaining that counted down correctly inside its own bucket. The tell only shows over one keep-alive connection, where the requests share a peer: the count descends there and resets on a fresh connection. Both demos now pass trustProxy: true, so the key is the forwarded client address. The comments say what the default keys on and what a CDN does to it, since this file is copied into every generated app and the old comment's "keyed by client IP by default" is the sentence that made the bug easy to write. The framework limiter needed no change. It behaves correctly on Node and on Bun locally, where the peer really is the visitor.
6 tasks
vivek7405
added a commit
that referenced
this pull request
Aug 10, 2026
`trustProxy: true` was not enough behind a CDN, and the gallery demo proved it on the live site after #1388 shipped. The default chain starts at the leftmost X-Forwarded-For entry, which behind Cloudflare is Cloudflare's egress address, not the visitor. Cloudflare pins an egress IP per connection, so the limiter handed out one bucket per connection: the count descended correctly for the page's probe button, which holds one connection, and reset for every fresh one, so no visitor was ever refused. rateLimit and clientIp now take a header name. When set it is the only wire header read, falling back to the stamped peer and then _anon_. A blank value falls through rather than becoming a key every visitor shares, and a comma chain is split so a proxy that appends cannot mint a bucket per hop. It needs trustProxy: true, because naming a header to trust is the trust decision. The framework does not prefer CF-Connecting-IP on its own. Cloudflare overwrites that header, which makes it unforgeable behind Cloudflare and forgeable everywhere else, so preferring it globally would let a client on an nginx or bare-platform deploy outrank the header the real proxy sets. Which header is trustworthy is a fact about the topology, so the app states it. The route-handler demo now reports the socket peer and the forwarded client side by side. The gap between those two was invisible from outside the app, which is what made this take two attempts to diagnose.
vivek7405
added a commit
that referenced
this pull request
Aug 10, 2026
`trustProxy: true` was not enough behind a CDN, and the gallery demo proved it on the live site after #1388 shipped. The default chain starts at the leftmost X-Forwarded-For entry, which behind Cloudflare is Cloudflare's egress address, not the visitor. Cloudflare pins an egress IP per connection, so the limiter handed out one bucket per connection: the count descended correctly for the page's probe button, which holds one connection, and reset for every fresh one, so no visitor was ever refused. rateLimit and clientIp now take a header name. When set it is the only wire header read, falling back to the stamped peer and then _anon_. A blank value falls through rather than becoming a key every visitor shares, and a comma chain is split so a proxy that appends cannot mint a bucket per hop. It needs trustProxy: true, because naming a header to trust is the trust decision. The framework does not prefer CF-Connecting-IP on its own. Cloudflare overwrites that header, which makes it unforgeable behind Cloudflare and forgeable everywhere else, so preferring it globally would let a client on an nginx or bare-platform deploy outrank the header the real proxy sets. Which header is trustworthy is a fact about the topology, so the app states it. The route-handler demo now reports the socket peer and the forwarded client side by side. The gap between those two was invisible from outside the app, which is what made this take two attempts to diagnose.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1387
The gallery's rate-limit card promises five requests per ten seconds and does not deliver one on the deployed site. Its middleware took the default bucket key, which is the socket peer, and behind Cloudflare plus Railway that peer is an edge proxy rather than the visitor. The pool has several addresses, each carrying its own full allowance, so the effective limit was five times the pool size and refreshing never produced a 429.
Nothing about it looked broken, which is why it survived. Every response still carried an
X-RateLimit-Remainingthat counted down correctly inside its own bucket. The tell only shows over one keep-alive connection, where the requests share a peer.200, remaining stuck at44, 4, 4, 3, 3, 2, 1, 44, 3, 2, 1, 0, then429What changed
Both demos pass
trustProxy: true, so the key is the forwarded client address. The comments now say what the default keys on and what a CDN does to it, because these files are copied into every generated app and the oldkeyed by client IP by defaultline is the sentence that made the bug easy to write.The framework limiter needed no change. It is correct on both runtimes; the local runs above are against the same code.
gallery/app/features/rate-limit/ping/middleware.tspackages/cli/lib/api-gallery.js, the api template's emitted copypackages/cli/templates/scripts/clear-gallery.mjs, sogallery:clearsheds the new card-owned test with its cardTest plan
gallery/test/rate-limit/rate-limit.test.ts, two tests. One asserts the window itself; the other asserts that a visitor exhausting their window does not refuse a different visitor behind the same proxy, which is the assertion the deployed bug fails.7912dc3d: removingtrustProxy: truefails the second test on its last assertion while the first still passes. That asymmetry is the point, since a peer-keyed limiter satisfies the single-visitor test too.test/scaffolds/scaffold-gallery.test.jsasserts both emitted middlewares carry the option, read from generated bytes rather than from the template source. 13/13 pass.apiapp and confirmed the emitted file strips and parses, since that half is emitted from a string template where a quoting slip only shows downstream.packages/server/test/rate-limit/*plustest/scaffolds/*: 99/99 pass.webjs checkclean ingalleryandwebsite.Docs
website/app/docs/rate-limiting/page.ts: the "Behind a proxy" section covered the shared-bucket direction but not the pool direction, which is the one that reads as working. Added it, with the keep-alive diagnostic..agents/skills/webjs/references/built-ins.md: the option list said the key "defaults to the client IP", which is the misleading phrasing. It now says socket peer and explains when that is not the visitor. The scaffold copy is generated from this file atprepack, so it follows automatically.Deliberately not here
Whether
clientIpshould preferCF-Connecting-IPover the client-appendable leftmostX-Forwarded-Forwhen both are present. That is a framework security question affecting everytrustProxy: trueapp rather than these demos, and it deserves its own issue rather than riding along with a demo fix.