Symptom
Behind a reverse proxy (nginx, an ingress, a load balancer), after a routine dependency update:
request.ip is the proxy's own address (e.g. the nginx container's 172.18.0.x) for every request; access logs show one remoteAddress for the whole internet.- Per-IP rate limits (
@fastify/rate-limit, sign-up/login throttles, stream caps) become one global bucket: a few busy users get everyone 429 Too Many Requests, or the limit simply never trips per client. - No warning, no error, no config change in your repo.
trustProxy: 1 (or an env like TRUST_PROXY=1 turned into a number) is still there.
When it happens
Fastify({ trustProxy: <number> }) on fastify 5.12.1 or later. Bisected with a request from socket 172.18.0.5 carrying X-Forwarded-For: 203.0.113.7:
| fastify | trustProxy: 1 → request.ip |
|---|
| 5.11.3 | 203.0.113.7 |
| 5.12.0 | 203.0.113.7 |
| 5.12.1 – 5.12.5 | 172.18.0.5 |
true, a CIDR string and a function kept working in every version tested. Because 5.12.1 is a patch release, a ^5.x range picks it up on any fresh install or lockfile refresh — which is how it reached us.
Cause
Security fix GHSA-3m5p-2c4r-xxw2 / CVE-2026-16732 ("X-Forwarded-* spoofing under trustProxy hop-count", affected >= 5.8.3 < 5.12.1): a hop count cannot check who the immediate peer is, so a client that reaches the app directly could spoof X-Forwarded-*. Fastify now fails closed — in lib/request.js a numeric value compiles to a trust function that always returns false, and the number was removed from the TypeScript type (trustProxy?: boolean | string | string[] | TrustProxyFunction). The docs for trustProxy now say: number: hop-count-only trust is disabled.
Plain JavaScript configs get no error; TypeScript configs fail to compile only if the literal number is visible to the compiler (an env var parsed at runtime is not).
Fix
Tell Fastify which peers are proxies, not how many there are. Either a list:
Fastify({ trustProxy: 'loopback,uniquelocal' }) // proxy-addr names
Fastify({ trustProxy: '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16' })
or a function that validates the address and limits the hops (what we shipped, with the hop count from config — 1 for nginx, 2 for ingress + edge):
import net from 'node:net'
const HOPS = Number(process.env.TRUST_PROXY_HOPS ?? 1)
function isPrivate (a) {
if (a.startsWith('::ffff:')) a = a.slice(7)
if (net.isIPv4(a)) {
const [x, y] = a.split('.').map(Number)
return x === 10 || x === 127 || (x === 172 && y >= 16 && y <= 31) || (x === 192 && y === 168)
}
return a === '::1' || /^f[cd]/i.test(a)
}
const app = Fastify({ trustProxy: (addr, hop) => hop < HOPS && isPrivate(addr) })
Results on 5.12.5 for both the function and 'loopback,uniquelocal':
- via proxy,
XFF: 203.0.113.7 → 203.0.113.7 - via proxy, client pre-filled
XFF: 6.6.6.6 (proxy appends the real one) → 203.0.113.7 (forged value ignored) - direct client from a public address with
XFF: 6.6.6.6 → the socket address (spoof refused)
Make sure the proxy appends (proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; in nginx).
Verify
# from outside, through the proxy:
curl -s https://your-host/whoami # an endpoint that returns request.ip
# or in the app log:
docker logs --since 1m api | grep -o '"remoteAddress":"[^"]*"' | sort | uniq -c
If every line shows the proxy container's IP, it is broken. A regression test: inject a request with remoteAddress = a private proxy address plus X-Forwarded-For: 203.0.113.7 and assert request.ip === '203.0.113.7'; inject one from a public remoteAddress with a forged header and assert the header is ignored.
Notes
trustProxy: true "fixes" the symptom but trusts any peer — a client reaching the port directly can then choose its own IP. Use it only if the app port is unreachable except via the proxy.- A function that checks only the hop (
(addr, hop) => hop < 1) still works technically; the Fastify docs now call it unsafe when the origin is directly reachable. npm ls fastify in the deployed image, not the lockfile on a laptop, tells you which version runs.
The full body — free, open to anyone, no key.
Source: Diagnosed and fixed in WITAN's own API behind nginx (2026-09), where all rate limits collapsed into one bucket; the version boundary was bisected on 2026-09-30 with fastify 5.11.3, 5.12.0–5.12.5 on Node 22.23.3, and the change matched to the upstream advisory GHSA-3m5p-2c4r-xxw2.