TRUSTED_PROXY_CIDRS: per-IP rate limits that work behind a proxy - #38
Conversation
There was a problem hiding this comment.
Important
Two fixes before merge: multi-value X-Forwarded-For only reads the first field line (spoofable rate-limit key behind a trusted peer), and the Unreleased CHANGELOG entry swallowed the Duplicate event-type body.
Reviewed changes
Initial review of TRUSTED_PROXY_CIDRS: opt-in trusted-proxy CIDRs so per-IP rate limits key on the real client behind a proxy/CDN instead of the shared TCP peer.
- Config + outermost middleware —
TRUSTED_PROXY_CIDRSloads as CSV CIDRs/bare IPs;TrustClientIPresolves once into request context;remoteIPprefers that overpeerIP. - Safe XFF walk — trusted peer only; right-to-left past trusted hops; malformed hop falls back to peer; vendor single-value headers deliberately ignored.
- Tests + claims/docs —
trustedproxy_test.gocovers spoof, walk, malformed, vendor, IPv6, and parse partial-failure; audit claim and ARCHITECTURE/DEPLOY updated for the conditional trust model.
ℹ️ Nitpicks
RateLimit's godoc still says the IP is the TCP remote address only (middleware.go~167–169);remoteIPalready documents the context path — worth aligning so the limiter comment does not contradict the new behavior.
Grok | 𝕏
Rate limits key on the TCP peer, and that is correct for a directly reachable instance and useless behind a fronting CDN, where every visitor arrives from the same few addresses and shares one bucket. TRUSTED_PROXY_CIDRS opts in per network: for a peer inside one of them the client IP comes from X-Forwarded-For, else the peer. The default does not move, and cannot be moved by a header. A peer that is not in the list has its headers ignored before they are read, so an instance that sets nothing behaves exactly as it did. Four details are the difference between this and a spoofable version. The X-Forwarded-For walk goes RIGHT TO LEFT past trusted hops and returns the first untrusted address, not the leftmost entry. The left of that header is whatever the original client sent, and every well-behaved proxy prepends to it and preserves it, so the leftmost value is chosen by the very party being rate limited. The rightmost non-trusted hop is the last address one of our own proxies actually observed. A hop that does not parse ends the walk and falls back to the peer rather than being skipped. Skipping it would let a client inject one malformed entry to push the walk past the real hop and onto a value it chose. X-Forwarded-For is read with Header.Values and joined, not Header.Get. Get returns only the FIRST field line, and a header may legitimately arrive as several: a client that sends its own line, in front of a proxy that adds a second rather than appending to the first, leaves the walk looking at a chain containing no trusted hop at all, so it returns the client's chosen address on its first step. RFC 9110 makes repeated field lines equivalent to one comma-joined value in the order received. Single-value vendor headers (CF-Connecting-IP, X-Real-IP, True-Client-IP) are not consulted at all, from any peer. Trusting one means trusting it from every network in the list, and the list names networks rather than CDNs: an ordinary reverse proxy in it forwards whatever headers the client sent, and the header carries nothing that says which hop wrote it. Nothing is lost by leaving them out, because a CDN sets X-Forwarded-For as well and its own ranges belong in the list anyway, so the walk steps over its edge address and lands on the same visitor the vendor header would have named. The audit claim about rate limits never reading proxy headers is updated rather than left to rot: it now states the condition under which they are read, and names the test that holds each half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c554c62 to
ca9d074
Compare
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes (since prior pullfrog review on c554c62)
Follow-up ca9d074 addresses both must-fix findings from the initial review.
- Join all
X-Forwarded-Forfield lines —resolveClientIPnow usesHeader.Values+strings.Joininstead ofGet, withTestTrustClientIP_joinsRepeatedForwardedForLinesusing twoHeader.Addlines so the client-seeded first line cannot hide proxy hops. - CHANGELOG structure restored — Duplicate event-type body is back under the
#17bullet;TRUSTED_PROXY_CIDRSis a self-contained entry that also documents the multi-line join. - Docs aligned — DEPLOY/ARCHITECTURE note the multi-line XFF read alongside the existing RTL / no-vendor-header rules.
Grok | 𝕏
|
Both review findings are fixed in Since #30 is locked, the rest of the split plan is here rather than there. Roughly in the order of appetite you described:
Happy to reorder if that is not the sequence you want, and happy to hold any of them if you would rather see fewer open at once. |

First of the split you asked for in #30, against
main, with no dialect layer under it. This is the one you said you most wanted. Nothing else from that pull request is in here: one setting, one resolver, one test file.What it does
TRUSTED_PROXY_CIDRSis a comma-separated list of networks (a bare address means one host). For a peer inside one of them,TrustClientIPresolves the client IP fromX-Forwarded-Forand puts it in the request context;remoteIPthen keys the rate limiter on that instead of the TCP peer. Empty by default, and an unconfigured instance behaves exactly as it does today: the headers are not read at all, so the default cannot be weakened by a header.One change from the version you reviewed
The #30 implementation preferred
CF-Connecting-IPwhen a trusted peer sent one. That was spoofable and is gone.Trusting a single-value vendor header means trusting it from every network in the list, and the list names networks, not CDNs. An ordinary reverse proxy inside it forwards whatever headers the client sent, and the header carries nothing that says which hop wrote it, so there is no way to tell the value Cloudflare set from the value a visitor typed. A client behind such a proxy could name its own rate-limit bucket with one header, which is exactly what the right-to-left walk exists to prevent.
It costs nothing to remove. A CDN sets
X-Forwarded-Fortoo, and its own ranges belong inTRUSTED_PROXY_CIDRSanyway, so the walk steps over its edge address and lands on the same visitor the vendor header would have named. The difference is that the answer now comes from a chain the code can reason about rather than from a header believed for a reason it cannot check.TestTrustClientIP_ignoresVendorHeadersFromATrustedPeerpins it forCF-Connecting-IP,X-Real-IPandTrue-Client-IP. Against the old implementation it fails exactly as you would want:1.2.3.4there is the value the client sent.The two details that make the walk safe
Right to left, not the leftmost entry. The left of
X-Forwarded-Foris whatever the original client sent; every well-behaved proxy prepends to it and preserves it, so the leftmost value is chosen by the party being rate limited. The rightmost non-trusted hop is the last address one of your own proxies actually observed.A hop that does not parse ends the walk and falls back to the peer rather than being skipped, so a client cannot inject one malformed entry to push the walk past the real hop onto a value it chose. This also means a proxy appending
ip:portrather than a bare address lands on the peer, which is the safe direction to be wrong in.Also updated
audit/claims.yaml'srate-limit-keys-on-tcp-source-addressclaim said the proxy headers are never read. That is now conditional, so the claim is rewritten rather than left to rot: it states the condition, records that an empty list means the original claim still holds verbatim, and names the test that holds each half.gofmt,go vetandgo test ./...are clean onmain+ this branch.🤖 Generated with Claude Code