The search API in my docs site used the first address in X-Forwarded-For as the rate-limit key. A nearby comment warned that clients could spoof that value unless the proxy handled incoming headers correctly.
The code enabled that trust by default. It didn’t require the deployment to have a trusted proxy in front of it.
A client that could choose the header could choose a new rate-limit bucket. The request counter still worked; the identity going into it wasn’t reliable.
The header needed a deployment policy
The old address selection was short:
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
It selected a value without establishing who had supplied it.
A proxy may append the address it sees to a forwarded-address chain, but position alone doesn’t establish trust. A usable security policy needs to know which proxies handle the request and how they treat headers supplied by clients.
Counting from the right can work with a known proxy topology. It isn’t a universal rule that the rightmost addresses are safe. An application reachable directly can receive a header with no trusted proxy involved at all.
The fix made forwarded-header handling opt-in through RATE_LIMIT_TRUSTED_PROXY_HEADER, with an explicit hop configuration. Without that setting, the resolver uses the server-provided request address.
That fallback still needs to be understood in the deployed environment. Astro exposes clientAddress, but the adapter and proxy configuration determine where it comes from. Describing it as impossible to forge would promise more than the application code establishes.
I prefer having the trust decision visible in deployment configuration. A comment about “common proxy setups” can’t tell the server which setup it is running in.
Address parsing and storage had separate limits
The change also replaced handwritten IP patterns with isIP from node:net. The resolver rejects a forwarded chain containing an invalid address.
A syntactically valid address isn’t necessarily trustworthy. Validation handles malformed input; the proxy policy handles whether the input should be believed. Both checks have a job.
The bucket store needed a bound as well. It was an in-memory map keyed by request-derived identities, so distinct keys could keep adding entries. The change capped the map and evicted the entry with the earliest reset time when full.
That limits retained state, not total traffic. Evicting a bucket can discard its rate-limit history. An in-memory limiter also doesn’t coordinate counters across separate server processes.
One fallback deliberately remained shared: requests with no usable address go into an unknown bucket. That can make unrelated clients share a limit. Giving every unidentified request a fresh bucket would avoid that contention by abandoning the limit instead.
Neither behavior is ideal. The shared bucket makes the limitation explicit without treating missing identity as permission for unlimited requests.
Test the identity before the counter
The fix included tests around proxy configuration, address resolution, bounded state, and the search endpoint. Those are more useful here than checking only that a counter eventually rejects requests.
A counter test can pass while a caller keeps selecting fresh identities.
The deployment deserves a check too: send a client-supplied forwarding header through the real request path and confirm it cannot choose the bucket. Changing the proxy topology should trigger that check again.
The original comment named the risk. The implementation needed a default and a deployment policy that addressed it.
Sources
- MDN: X-Forwarded-For — proxy trust, header parsing, and security-sensitive address selection.
- Astro API reference — the
clientAddressAPI. - semantic-docs PR #93 — explicit proxy configuration, bounded limiter state, and regression coverage.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].