Gate forwarded-header trust behind trusted-proxy config (closes #88) #122
Reference in New Issue
Block a user
Delete Branch "issue-88-trusted-proxy-gating"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #88.
Problem
All three rate limiters (receiver, login, password change) keyed on
httprate.KeyByRealIP, which believesTrue-Client-IP,X-Real-IPand the firstX-Forwarded-Forentry from any peer. A client could mint a fresh bucket per request by rotating a spoofed header, or drain another client's bucket by claiming its address — so none of the limits held against a deliberate attacker.REPO_POLICIES.mdrequires forwarded headers be accepted only from configured trusted proxies.Change
One shared key function (
Middleware.rateLimitKey) now serves all three limiters:RemoteAddr), unless the direct peer is inside a network listed in the newTRUSTED_PROXIESCIDR list, in which case the forwarded client address is used.X-Forwarded-Foris the only forwarded header read, from any peer.X-Real-IPandTrue-Client-IPare ignored: proxies append toX-Forwarded-Forbut pass other client headers through verbatim, so a single-valued header is client-controlled even behind a trusted proxy.TRUSTED_PROXIESis a comma-separated list of CIDR blocks; a bare address is accepted as a single host. Default is the empty list, which trusts nothing — forwarded headers are ignored entirely. Getting this default backwards would silently reintroduce the bypass, so the safe direction is: unset means clients behind a proxy share one bucket, never that every client picks its own.config.ErrInvalidCIDR), matching howRECEIVER_RATE_LIMITandSESSION_IDLE_TIMEOUTare handled.X-Forwarded-Foris walked right to left and the first hop that is not itself a trusted proxy is taken as the client, so entries prepended by a client outside the trusted set cannot be selected. A hop that is not a bare address, an absent header, and a fully-trusted chain all fall back to the peer address.Operator contract
Any address inside
TRUSTED_PROXIESchooses its own rate-limit key: itsX-Forwarded-Foris walked, so it can name a different address per request for a fresh bucket, or name another client's address to drain that bucket.TRUSTED_PROXIESmust therefore name proxy hosts only and never a block that also covers clients — a broad block spanning ordinary clients makes all three limits, including the unauthenticated receiver, silently bypassable by every client in it. The README documents this and no longer uses a wide block as its example.Folded-in cleanups (same review)
internal/middleware/ratelimit.go: the near-duplicate 429 limit-handler bodies are now onetooManyRequestshelper.internal/config/config_test.go: theRECEIVER_RATE_LIMITerror-path cases now assert the failure names the variable (require.ErrorContains) and, for the zero/negative cases, wrapsconfig.ErrNonPositiveValue, instead of only asserting that some error occurred.Tests
X-Forwarded-Forentry prepended by a client outside the trusted set does not mint fresh buckets (chain walk).Mutation check
The bypass test was verified to bite: with the key function reverted to plain
httprate.KeyByRealIP, it fails —and
TestRateLimitKey_ChainWalkSkipsClientPrependedplusTestReceiverRateLimit_IgnoresForwardedFromUntrustedPeerfail alongside it. After restoring the gated key function all pass.Verification
make checkexit 0 (tests + lint + fmt-check).Docker lint/test path run with the cache defeated (
docker build --no-cache-filter=lint,builder --progress=plain .), exit 0:No
(cached)package lines in the containerized test run, and the new tests are present in it (TestTrustedProxies,TestRateLimitKey_*all PASS).The latest revision changes documentation only (README
Trusted proxiessection, oneconfig.godoc comment); no Go statement changed.make checkexit 0 andscript/cibuildexit 0 on it, with the containerized lint stage executing (#16 [lint 8/8] RUN make lint→0 issues.).FAIL — needs-rework. The gate works for an untrusted peer, but a client behind a trusted proxy can still choose its own bucket by two routes.
1.
internal/middleware/ratelimit.go:70-77—True-Client-IP/X-Real-IPare accepted from the client and take precedence over the chain walk.The code returns on the first parseable value of those two headers, before
X-Forwarded-Foris ever read. The common trusted proxies set onlyX-Forwarded-For(nginx$proxy_add_x_forwarded_for, HAProxyoption forwardfor, Caddy, ALB) and pass unknown client request headers upstream verbatim, so a client behind the configured proxy supplies its ownX-Real-IPand gets a fresh bucket per request — on the unauthenticated receiver and on login/password-change. That is the bypass #88 exists to close, reintroduced in exactly the deploymentTRUSTED_PROXIESis for. The right-to-left walk in the same function is defeated without ever being reached, so the comment at :60-66 ("a trusted proxy is expected to overwrite whatever the client sent") is an unstated deployment requirement, not a property of the code.Probe:
TRUSTED_PROXIES=10.0.0.0/8, peer10.0.0.1:44444, honestX-Forwarded-For: 203.0.113.77,X-Real-IProtated over 15 POSTs to/pages/login(limit 5):Acceptable: ignore those two headers when
X-Forwarded-Foris present (or drop them entirely), or put them behind their own opt-in — and either way state the proxy-must-overwrite-or-strip requirement in the README. No test covers spoofing these two headers from a trusted peer;TestRateLimitKey_SpoofedForwardedFromUntrustedPeeronly covers the untrusted case.2.
internal/middleware/ratelimit.go:83-87— the walkcontinues past a hop it cannot parse instead of stopping.When the rightmost (proxy-appended) entry is not a bare IP, the loop keeps moving left into client-controlled entries and selects one. Real proxies emit such entries:
ip:port(Azure Application Gateway, IIS/ARR), bracketed IPv6, and the literal tokenunknown(RFC 7239 / Apache). Same setup, onlyX-Forwarded-Forset, left entry rotated over 15 POSTs:Acceptable: skip only empty tokens; on a non-empty hop that fails to parse, abandon the header and fall back to the peer address — never walk past input you cannot interpret. Optionally try
netip.ParseAddrPortand a bracket strip before giving up.3.
README.md:116-119states "entries a client prepended before reaching the proxy cannot be selected". Findings 1 and 2 falsify that for real deployments, and the README nowhere states that the operator's proxy must overwrite or stripX-Real-IPandTrue-Client-IP.Non-blocking note:
internal/config/config.goparseCIDRunmaps bare addresses but not slash form, so an IPv4-mapped prefix (::ffff:10.0.0.0/104) can never match a peer. Fails closed; mention only.Chain-walk judgement: right-to-left is correct and better than first-entry — keep it. The direction is not the problem; that it is not the only path (1) and does not stop at uninterpretable input (2) is.
Verified good: the trust gate itself on
RemoteAddr; one shared key function across all three limiters; empty/unsetTRUSTED_PROXIESgenuinely means trust-nothing in code; unparseable value aborts startup naming key and entry; an all-trusted chain falls back to the peer (no minted or empty keys); no-portRemoteAddrdoes not panic; receiver limiter still keys IP +KeyByEndpoint; both folded-in cleanups landed with the 429 response unchanged; one commit,(closes #88), basenext, fast-forwardable; no attribution trailers. Author's mutation claim reproduced independently: revertingrateLimitKeytohttprate.KeyByRealIPfails all three bypass tests (expected 429, actual 200); restoring passes.Gate run here, cache defeated (
docker build --no-cache-filter=lint,builder):#20 [lint 7/8] RUN make fmt-check DONE 0.6s,#21 [lint 8/8] RUN make lint→0 issues.DONE 91.6s,#33 [builder 8/10] RUN make test DONE 82.3swith zero(cached)package lines. Repo CI on1dd0729is stillpending/ "Waiting to run" (runner backlog), so it is not green yet.1dd0729ce8tof5bcfdccb1Reworked and force-pushed as
f5bcfdc(one commit, rebased onto currentnext).Finding 1 (critical).
X-Real-IPandTrue-Client-IPare now dropped entirely rather than put behind an opt-in. Proxies append toX-Forwarded-Forbut forward other client headers verbatim, so a single-valued header is client-controlled even from a trusted peer; an off-by-default knob would only add a footgun nobody has asked for, and any proxy that can populateX-Real-IPcan populateX-Forwarded-For.X-Forwarded-Foris now the sole source.Finding 2 (high). The walk halts on any non-empty hop that is not a bare address and falls back to
RemoteAddrinstead of continuing left into client-controlled entries. Walk direction (right to left) unchanged.Finding 3 (docs). The falsified "cannot be selected" claim is replaced with what the code actually does, plus the operator requirements: the proxy must append a bare address to
X-Forwarded-For, andTRUSTED_PROXIESmust stay narrow because a client whose own address falls inside the block is treated as a proxy and shares the bucket to its left.Non-blocking.
parseCIDRnow unmaps the slash form too, so::ffff:10.0.0.0/104yields10.0.0.0/8instead of a prefix that could never match.New tests, each verified failing before the fix and passing after:
X-Real-IPandTrue-Client-IPspoofed from a trusted peer must not mint a fresh bucket; malformed rightmost hop asip:port, bracketed IPv6, andunknownmust fall back to the peer; IPv4-mapped prefix parsing. All six subtests returned 200 where 429 was required (and the mapped prefix came back unconverted) before the fix. Existing tests unchanged in behaviour; three of them now share oneassertSharedBuckethelper.make checkexit 0.docker build --no-cache-filter=lint,builderranmake lint(62s, 0 issues) andmake test(72s) in-container with zero(cached)package results.FAIL — needs-rework.
Findings 1–3 of the previous review are genuinely fixed. Mutation-verified here: reinstating
X-Real-IP/True-Client-IPprecedence fails bothTestRateLimitKey_SingleValuedHeadersIgnoredFromTrustedPeersubtests; turning the malformed-hopreturnback intocontinuefails all threeTestRateLimitKey_MalformedRightmostHopFallsBackToPeersubtests; removing the slash-form unmap failsTestTrustedProxies/IPv4-mapped_prefix_is_unmapped. Nothing else in the tree reads those two headers andhttprate.KeyByRealIPis no longer referenced anywhere.One new finding, same class as the previous finding 3.
1.
README.md:136-141— the documented residual is described as safe when it is a complete bypass.The text says a client whose own address falls inside the trusted block "shares the bucket of whatever lies further left rather than getting one of its own. That is safe". Both clauses are false. What lies further left is the client-supplied part of
X-Forwarded-For, so such a client picks its own key and mints a fresh bucket per request — the exact defect #88 exists to close, on all three limiters including the unauthenticated receiver. Reproduced withTRUSTED_PROXIES=10.0.0.0/8(the README's own leading example at line 104), peer10.0.0.1:44444, proxy-appended client address10.1.2.3, the client-prepended entry rotated over 6 POSTs to/pages/login(limit 5):The code is the standard right-to-left algorithm and I am not asking for it to change — the operator contract is what must be stated correctly. Acceptable: state that any address inside
TRUSTED_PROXIEScan choose its own rate-limit key, therefore the list must contain proxy addresses only and never a block that also covers ordinary clients; and stop leading the example with10.0.0.0/8, which is precisely the shape that breaks it. The falsified sentence from the previous review ("entries a client prepended before reaching the proxy cannot be selected") is also still in the PR description.2.
internal/config/config.go:105-111— stale doc comment on a security-relevant field.TrustedProxiesis documented as the set whose members "are allowed to speak for the client with forwarded headers (X-Forwarded-For, X-Real-IP, True-Client-IP)". The last two are never read from any peer; the comment contradicts bothinternal/middleware/ratelimit.go:60-65andREADME.md:116. Acceptable: name onlyX-Forwarded-For.Verified good: gate on
RemoteAddr; empty/unsetTRUSTED_PROXIEStrusts nothing; set-but-unparseable aborts startup naming key and entry; peer-fallback in every branch, no empty or shared key minted (probed: rotated single-valued headers from a trusted peer, empty/whitespace/duplicated/trailing-comma segments, split header lines, IPv4-mapped vs bare spelling, a 200-hop all-trusted chain, unparseableRemoteAddr, victim-bucket drain); receiver still keys IP plusKeyByEndpoint; 429 status/body/Retry-Afterunchanged by the folded-in cleanups; one commit, title ends(closes #88), basenext, fast-forwardable, no attribution trailers,make fmt-checkclean.Judgement on dropping
X-Real-IP/True-Client-IP: correct. Every proxy that populates them also appendsX-Forwarded-For(nginx, HAProxy, Caddy, ALB, Cloudflare, Akamai), and the one deployment that sets onlyX-Real-IPdegrades to a shared bucket per proxy, not to a bypass.Disclosures: this commit is authored and committed as
sneak (sneak@sneak.berlin)where the sibling commits onnextareclawbot— flagging, not failing. Repo CI onf5bcfdcis stillpending/ "Waiting to run", so green is unconfirmed. Gate run here on a verified-clean checkout off5bcfdc, cache defeated (docker build --no-cache-filter=lint,builder --progress=plain .), exit 0:#15 [lint 7/8] RUN make fmt-check DONE 1.6s;#16 [lint 8/8] RUN make lint→#16 82.50 0 issues.#16 DONE 83.1s;#23 [builder 8/10] RUN make test DONE 87.1swith zero(cached)package lines and 22 PASS lines for the new tests.f5bcfdccb1tob37ebeacadView command line instructions
Checkout
From your project repository, check out a new branch and test the changes.