fix(http)!: key on the hop the proxy wrote, not the one the client sent - #2
Merged
Merged
Conversation
clientIp returned the FIRST x-forwarded-for hop, with a comment asserting that was "what the proxy saw" and a test pinning it. Both were wrong in the same direction. A reverse proxy APPENDS to X-Forwarded-For. Caddy and nginx both do. So for a request through one proxy the header reads `<whatever the client sent>, <what the proxy actually saw>`, and the only entry a client cannot forge is the LAST. The effect, in every adopter at once: send a random X-Forwarded-For with each request and every request lands in a fresh bucket, so no bucket ever fills. Not "weakened" — bypassed completely. A limiter that cannot be tripped is not a limiter, and it is worse than none, because the route reads as protected. Found in orangecat's payments audit (bitbaum/orangecat#563, finding 2), where the same mistake is copied across three payment routes that each mint a real Lightning invoice per request. Now counts from the right. `trustedProxies` (default 1) says how many proxies of your own sit in front; a CDN in front of Caddy is 2 and moves the answer one further left. `trustedProxies: 0` means the server is exposed directly, so every forwarded header is client-written and none is evidence — the result is "unknown" rather than a number that looks like proof. Verified against the fleet's actual topology rather than assumed: DNS resolves straight to the box, no CDN, one Caddy 2.11.4 with no trusted_proxies configured, so exactly one hop is appended. BREAKING: the returned identity changes behind a proxy — which is the point. v0.2.0. Adopters pinning #v0.1.0 keep the bypass until they move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018waGt1ieA9TjpscqrbrnGb
github-actions Bot
pushed a commit
to bitbaum/orangecat
that referenced
this pull request
Aug 28, 2026
…#816) Every keyed limiter here could be bypassed completely by sending a random X-Forwarded-For with each request. A new header value is a new bucket, so no bucket ever fills. Not a weakened limiter — no limiter, on routes that read as protected. X-Forwarded-For is a LIST and Caddy APPENDS to it, so a request that reached us through Caddy carries `<whatever the caller sent>, <what Caddy actually saw>`. Only the LAST entry is unforgeable. Six places read that header and every one read it wrong: rate-limit.ts used it whole; the three payment routes each copied `split(',')[0]`; the entity audit log recorded that value, making the trail writable by its own subject; the captcha route forwarded it to the provider as remoteip, feeding its IP heuristics whatever the caller chose. The sharp end is payments. GET /api/v1/pay/... and POST /api/v1/payments/public each mint a real Lightning invoice through the RECIPIENT's wallet, so unlimited requests are how you get a seller throttled or banned by their own wallet provider and litter their queue with orphan intents (#563 finding 1, which this unblocks). Verified rather than assumed: orangecat.ch resolves straight to the box, no CDN (`via: 1.1 Caddy`), one Caddy 2.11.4 with no trusted_proxies configured — so exactly one hop is appended and the rightmost is the real client. One definition now, in src/lib/client-ip.ts. Its own module rather than a corner of rate-limit.ts so the audit log and the captcha route can ask who is calling without importing an Upstash client. clientIpOrUndefined is the same answer where an ADDRESS is wanted: an audit row wants nothing rather than the word "anonymous", which is a limiter concept and not a place. Six instances is well past the point where fixing them once more is the answer, so check:client-ip joins verify: nothing outside that module may read the raw header. Proven to fail before shipping — reintroducing the old expression exits 1, and the clean tree exits 0. limitkit@0.2.0 carried the identical bug and is fixed there too (bitbaum/limitkit#2), so its adopters get this without waiting for ADR-0002's full unification here. Claude-Session: https://claude.ai/code/session_018waGt1ieA9TjpscqrbrnGb Co-authored-by: Georgy Butaev <41178744+g-but@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.
clientIpreturned the firstx-forwarded-forhop, with a comment asserting that was "what the proxy saw" and a test pinning it. Both were wrong in the same direction.A reverse proxy appends to
X-Forwarded-For— Caddy and nginx both do. So for a request through one proxy the header reads<whatever the client sent>, <what the proxy actually saw>, and the only entry a client cannot forge is the last.The effect, in every adopter at once
Send a random
X-Forwarded-Forwith each request and every request lands in a fresh bucket, so no bucket ever fills. Not "weakened" — bypassed completely. A limiter that can't be tripped is worse than none, because the route reads as protected.Found in orangecat's payments audit (bitbaum/orangecat#563, finding 2), where the same mistake is copied across three payment routes that each mint a real Lightning invoice per request.
The fix
Counts from the right.
trustedProxies(default 1) says how many proxies of your own sit in front; a CDN in front of Caddy is2and moves the answer one further left.trustedProxies: 0means the server is exposed directly, so every forwarded header is client-written and none is evidence — the result is"unknown"rather than a number that looks like proof.Verified against the real topology, not assumed
DNS for
orangecat.chresolves straight to the box, no CDN in front (via: 1.1 Caddy), one Caddy 2.11.4 with notrusted_proxiesconfigured — so exactly one hop is appended, and the default of 1 is correct for this fleet.Breaking
The returned identity changes behind a proxy — which is the point. v0.2.0. Adopters pinning
#v0.1.0keep the bypass until they move; fleetcrown is the one current adopter and needs its pin bumped.npm run verifygreen — lint, typecheck, build, 16 tests (7 new, covering the spoofed-prefix bypass,trustedProxies0/2/over-length, and separators-only input).