Skip to content

fix(http)!: key on the hop the proxy wrote, not the one the client sent - #2

Merged
catomean merged 1 commit into
mainfrom
fix/client-ip-trusts-the-proxy
Aug 28, 2026
Merged

fix(http)!: key on the hop the proxy wrote, not the one the client sent#2
catomean merged 1 commit into
mainfrom
fix/client-ip-trusts-the-proxy

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

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 can't be tripped is worse than none, because the route reads as protected.

// before: three requests from one client, three different keys
clientIp(h({ 'x-forwarded-for': '1.1.1.1, 203.0.113.7' }))  // '1.1.1.1'  ← attacker's value
clientIp(h({ 'x-forwarded-for': '2.2.2.2, 203.0.113.7' }))  // '2.2.2.2'
// after: one key, every time
                                                            // '203.0.113.7'

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 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 real topology, not assumed

DNS for orangecat.ch resolves straight to the box, no CDN in front (via: 1.1 Caddy), one Caddy 2.11.4 with no trusted_proxies configured — 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.0 keep the bypass until they move; fleetcrown is the one current adopter and needs its pin bumped.

npm run verify green — lint, typecheck, build, 16 tests (7 new, covering the spoofed-prefix bypass, trustedProxies 0/2/over-length, and separators-only input).

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
@catomean
catomean merged commit 75adef1 into main Aug 28, 2026
1 check passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant