fix(sdk): stop taking the client IP from a header the client writes - #24
Merged
Merged
Conversation
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
This was referenced Aug 22, 2026
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.
The bug
The Express and Next.js adapters resolved the client IP by taking the leftmost value of
X-Forwarded-For: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.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 owntrustProxyoption.The fix
A shared resolver in
@webdecoy/nodethat counts trust from the right of the chain — the end written by infrastructure the operator controls, rather than the end the client writes.trustProxyfalse1,2, …'cloudflare'CF-Connecting-IP.['10.0.0.0/8', …]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, socheck:edgestays green and this works in Edge and Workers runtimes.Also exported for applications that build their own
RequestMetadata:resolveClientIp(),normalizeIp(),ipInCidr(), and theTrustedProxiestype. 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:
req.ip, which honoursapp.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 — settrust proxyor passtrustProxy.1trusted hop. Edge middleware has no socket to fall back on, so a believe-nothing default is not available;1is correct on Vercel and any single-proxy deployment.getIPstill 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 fourtrustProxyshapes, 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, againstrateLimit({ max: 1 }), must come back[200, 403, 403]. Before this change they were[200, 200, 200].npm run build,npm test(306 tests) andnpm run check:edgeall pass. Repo lint is broken independently of this change — there is no eslint config anywhere in the tree — and CI does not run it.