Skip to content

fix(sdk): stop taking the client IP from a header the client writes - #24

Merged
cport1 merged 1 commit into
mainfrom
fix/trusted-proxy-client-ip
Aug 22, 2026
Merged

cport1 merged 1 commit into
mainfrom
fix/trusted-proxy-client-ip

Conversation

@cport1

@cport1 cport1 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

The bug

The Express and Next.js adapters resolved the client IP by taking the leftmost value of X-Forwarded-For:

return forwardedFor.split(',')[0].trim();

That value is written by the client for the first hop. It is not evidence of anything, and we keyed everything on it:

  • rateLimit() was bypassable with one header. A fresh bucket per forged address means the rule provided no protection against the traffic it exists to stop.
  • Every reported violation carried an address of the caller's choosing, and so did anything built on top of one.
  • filter({ expression: 'ip.tor or ip.vpn' }) was an opt-in check — and we spent an enrichment lookup on an invented address.

The captcha endpoints in both adapters had the same flaw, where it would have let a solver farm mint challenges under a new address per request.

Fastify was already fine: it deferred to request.ip, which honours the server's own trustProxy option.

The fix

A shared resolver in @webdecoy/node that counts trust from the right of the chain — the end written by infrastructure the operator controls, rather than the end the client writes.

app.use(webdecoy({ trustProxy: 1 }));   // one proxy in front of this app
trustProxy Meaning
false Read no forwarding headers. The socket address is the client.
1, 2, … Trusted hops. The client is the Nth entry from the right.
'cloudflare' Use CF-Connecting-IP.
['10.0.0.0/8', …] CIDRs of your proxies; walk right to left past them.

Addresses are normalised before anything keys on them: ports, brackets and IPv6 zone ids stripped, IPv4-mapped IPv6 collapsed to its IPv4 form so a dual-stack listener keys one client once, leading-zero octets rejected rather than parsed. Anything malformed falls back to the peer address instead of becoming a key of its own — the peer is the one address on a request that cannot be forged, so a wrong-but-real proxy address beats a plausible value the caller chose.

No node:net, so check:edge stays green and this works in Edge and Workers runtimes.

Also exported for applications that build their own RequestMetadata: resolveClientIp(), normalizeIp(), ipInCidr(), and the TrustedProxies type. Deriving the address a second way is how the middleware and the app end up disagreeing about who the caller is.

Behaviour change

Worth calling out in the release notes:

  • Express now defers to req.ip, which honours app.set('trust proxy', …) and otherwise resolves to the socket address. Apps that already configured Express need no change. An app behind a proxy that never configured it will now attribute traffic to the proxy — set trust proxy or pass trustProxy.
  • Next.js reads from the right and defaults to 1 trusted hop. Edge middleware has no socket to fall back on, so a believe-nothing default is not available; 1 is correct on Vercel and any single-proxy deployment.
  • Fastify is unchanged, and gains the option for parity.
  • getIP still overrides everything.

Suggested release: 0.12.0 (the doc comments reference it).

Tests

  • packages/webdecoy/src/client-ip.test.ts — 51 cases: normalisation, CIDR matching including mid-byte prefixes and cross-family, all four trustProxy shapes, IPv6, repeated headers, malformed entries, empty chain entries.
  • packages/express/src/trusted-proxy.test.ts — 5 end-to-end cases through a real Express app. The first is the regression: three requests from one machine each claiming a different origin, against rateLimit({ max: 1 }), must come back [200, 403, 403]. Before this change they were [200, 200, 200].

npm run build, npm test (306 tests) and npm run check:edge all pass. Repo lint is broken independently of this change — there is no eslint config anywhere in the tree — and CI does not run it.

The Express and Next.js adapters read the leftmost X-Forwarded-For value
and called it the caller's address. That value is written by the client
for the first hop, so it was never evidence of anything: one
-H 'X-Forwarded-For: 1.2.3.4' bought a fresh rate-limit bucket per forged
address, put an address of the caller's choosing on every violation we
reported, and made filter({ expression: 'ip.tor' }) an opt-in check. The
captcha endpoints in both adapters had the same flaw.

Adds a shared resolver in @webdecoy/node that counts trust from the RIGHT
of the chain, which is the end written by infrastructure the operator
controls. trustProxy is false (believe nothing), a number of hops,
'cloudflare', or CIDRs to walk past. Addresses are normalised first --
ports, brackets and zone ids stripped, IPv4-mapped IPv6 collapsed -- and
anything that does not parse falls back to the peer address rather than
becoming a key of its own. No node:net, so it runs on Edge and Workers.

Behaviour change: Express now defers to req.ip (which honours the app's
own `trust proxy` setting), and Next.js reads the chain from the right
with a default of one trusted hop, since edge middleware has no socket to
fall back on. Fastify already deferred to request.ip and is unchanged.
getIP still overrides everything.

Closes WebDecoy/app#725
@cport1
cport1 merged commit 751bada into main Aug 22, 2026
2 checks passed
@cport1
cport1 deleted the fix/trusted-proxy-client-ip branch August 22, 2026 01:35
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