fix: resolve client IP from the rightmost untrusted X-Forwarded-For entry - #26646
Conversation
The X-Forwarded-For header was parsed left-to-right, so the leftmost (client-controlled) value was accepted as the real client IP when the request arrived from a trusted proxy. That spoofable IP feeds per-IP login rate limiting and audit log source addresses. This test reproduces the spoof with a realistic trusted-proxy CIDR and currently fails: it asserts the rightmost non-trusted address (the real client) is used instead of the forged leftmost value. The fix follows in a subsequent commit.
Forwarding headers were parsed left-to-right, returning the leftmost comma-separated value. Reverse proxies append the peer that connected to them, so the leftmost value is client-controlled and could be forged to spoof the IP used for per-IP login rate limiting and audit log source addresses. Parse forwarding header chains right-to-left and return the first address that is not a trusted origin (the real client). When every hop is a trusted origin, fall back to the leftmost address to preserve behavior for broad-trust configurations. Whitespace around each hop is trimmed. This makes the recovered IP non-forgeable while leaving existing behavior unchanged when the trusted-origin set covers the whole chain.
A deep review found a residual spoofing vector: Header.Get returns only the first X-Forwarded-For field line, so a proxy that appends its hop as a separate header line left the client-controlled first line as the selected address. Per RFC 7230, multiple field lines with the same name are equivalent to one comma-separated value. Join all X-Forwarded-For field lines before selecting the rightmost untrusted address. Single-value forwarding headers (X-Real-Ip, Cf-Connecting-Ip, True-Client-Ip) keep first-value semantics via Header.Get. Adds a multiple-header-lines regression case and refines the helper comment to note the rightmost-untrusted guarantee holds when all trusted proxy hops are listed in TrustedOrigins.
|
/coder-agents-review |
|
Chat: Review posted | View chat Review historydeep-review v0.9.0 | Round 3 | Last posted: Round 3, 6 findings (3 P3, 3 Nit), APPROVE. Review Finding inventoryFindings
Contested and acknowledged(none) Round logRound 1Panel (14 reviewers). CRF-1 from Netero. CRF-2 convergent across 5 reviewers. CRF-3 convergent across 3 reviewers (verified mitigated by current call sequence). 3 P3, 3 Nit posted. 2 P4 dropped. Reviewed against 51591e3..3283dd7. Round 2BLOCKED. CRF-4, CRF-5, CRF-6 addressed (test refactored into TestExtractAddress table). CRF-1, CRF-2, CRF-3 silent. No review. Round 3Panel (11 reviewers). All 8 findings resolved. Netero clean. 0 new findings from panel. Reviewed against 51591e3..d49a8e0. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
Solid security fix. The rightmost-untrusted algorithm is the correct defense against XFF spoofing, the multi-line header join closes a second vector, and the test-first approach (commit c685865 writes the failing test before the fix) shows genuine rigor. 14 reviewers examined this; Pariston's verdict: "I tried to build a case against this and could not."
3 P3, 3 Nit. No blocking issues.
CRF-2 and CRF-3 are on code outside this diff. CRF-2 is the stale getRemoteAddress comment that teaches the exact vulnerability pattern this PR fixes; five reviewers independently flagged it. CRF-3 is a pre-existing sibling pattern in FilterUntrustedOriginHeaders that is currently mitigated by the call sequence in proxyWorkspaceApp (line 563-566) where EnsureXForwardedForHeader rebuilds XFF from the already-resolved RemoteAddr. Worth fixing for structural safety, but not blocking.
Notable: extractForwardedAddress is called for all trusted headers, not just XFF. Five reviewers analyzed this and all confirmed it's a no-op for realistic single-value headers. If a non-XFF header ever carries commas, the new behavior (rightmost untrusted) is strictly more secure than the old (leftmost).
coderd/httpmw/realip.go:198
P3 [CRF-2] getRemoteAddress doc comment and inline comment still teach the exact security assumption this PR fixes.
"the first value is the client address"
No it isn't. That's the whole point of this PR. After this change, extractForwardedAddress handles XFF parsing with right-to-left trust evaluation. But the comment still tells the next reader that "the first value is the client address." If someone reads this and uses getRemoteAddress directly for a new forwarding header integration, the same spoofing vulnerability comes back.
The doc should describe what the function does (extracts the first comma-delimited IP from a string, stripping ports) without claiming it yields "the client address" or "the original address." The inline XFF comment should be removed or redirected to extractForwardedAddress.
(Leorio P3, Ryosuke Nit, Robin Nit, Meruem Nit, Gon Note)
🤖
coderd/httpmw/realip.go:113
P3 [CRF-3] FilterUntrustedOriginHeaders collapses multi-line XFF to the first (client-controlled) header line, the same class of bug this PR fixes in ExtractRealIPAddress.
req.Header.Set(header, req.Header.Get(header)) drops all but the first field line. In a multi-line spoofing scenario (X-Forwarded-For: spoofed\r\nX-Forwarded-For: real, 10.0.0.2), only spoofed survives.
Currently mitigated: the sole caller in proxyWorkspaceApp (proxy.go:563-566) immediately rebuilds XFF via EnsureXForwardedForHeader from the already-resolved RemoteAddr. But the mitigation is incidental, not structural; the two functions are not documented as a required pair. If FilterUntrustedOriginHeaders is ever called without EnsureXForwardedForHeader following, the multi-line bypass re-emerges.
Pre-existing, not introduced by this PR.
(Meruem P3, Kurapika Note)
🤖
🤖 This review was automatically generated with Coder Agents.
…tAddress The spoofing scenarios were in a separate TestExtractRealIPSpoofedXForwardedFor function whose direct ExtractRealIPAddress assertions duplicated the existing TestExtractAddress table. Move the single-proxy, chained-proxy, and multiple-header-line cases into that table, which also adds the previously missing narrow-CIDR (10.0.0.0/8) coverage alongside the existing 0.0.0.0/0 cases. The middleware-level RemoteAddr rewrite remains covered by TestTrustedOrigins.
Rename the spoofing cases to describe the behavior each verifies, and repurpose the redundant single-proxy case (which duplicated the chained case) to cover the no-trusted-origins scenario where X-Forwarded-For is ignored: - no-trusted-origins: forwarding headers ignored without trusted origins - picks-rightmost-untrusted: rightmost non-trusted entry wins - x-forwarded-for-set-multiple-times: multiple header lines are joined
The no-trusted-origins behavior is already covered by default-empty-config.
|
/coder-agents-review |
There was a problem hiding this comment.
CRF-4, CRF-5, CRF-6 (all Nit) addressed in commit 5842b6e; the spoof tests were refactored into the TestExtractAddress table, removing the redundant assertion, naming inconsistency, and restatement comment.
Three P3 findings remain with no author response:
CRF-1 (realip_test.go): No test for the all-trusted-origins fallback path in extractForwardedAddress. The new test cases ("picks-rightmost-untrusted", "x-forwarded-for-set-multiple-times") both include untrusted addresses; neither exercises the leftmost fallback when every parsed hop is trusted. A case like XFF 10.0.0.1, 10.0.0.2 with 10.0.0.0/8 trusted would cover it.
CRF-2 (realip.go:198): getRemoteAddress doc and inline comments still say "the first part is the original address" and "the first value is the client address." This teaches the exact vulnerability pattern this PR fixes.
CRF-3 (realip.go:113): FilterUntrustedOriginHeaders still collapses multi-line XFF to the first header line via Header.Set(header, Header.Get(header)). Pre-existing and currently mitigated by the call sequence, but same class of bug.
Further review is blocked until these findings receive a response: fix in this PR, file a GitHub issue for later, or explain why the current code is acceptable.
🤖 This review was automatically generated with Coder Agents.
Address review findings on the X-Forwarded-For handling: - FilterUntrustedOriginHeaders joined multiple X-Forwarded-For field lines for trusted origins instead of collapsing to the first line, so later hops are no longer dropped. The resolved real IP was already mitigated by EnsureXForwardedForHeader, but this removes a same-class latent footgun. - getRemoteAddress docs no longer describe the leftmost value as the client address, which is the parsing pattern this changeset moves away from. - Add table cases for the all-trusted leftmost fallback and for joining multiple X-Forwarded-For field lines during filtering.
|
/coder-agents-review |
There was a problem hiding this comment.
All 6 posted findings from R1 are resolved. CRF-1 (all-trusted fallback test) added. CRF-2 (stale comment) rewritten to describe the function's actual behavior and direct readers to extractForwardedAddress. CRF-3 (FilterUntrustedOriginHeaders multi-line XFF) fixed with the same strings.Join(Header.Values(...)) pattern and covered by a new test. CRF-4, CRF-5, CRF-6 (Nits) cleaned up by refactoring spoof tests into the existing TestExtractAddress table.
11 reviewers, 0 new findings. Netero clean. Pariston ran five adversarial framings and found no scenario where the fix fails for a correctly configured deployment. Mafuuu traced every contract promise against the code and tests. Kurapika confirmed the security trace end-to-end including the EnsureXForwardedForHeader call ordering.
Leorio on the extractForwardedAddress doc comment: "Every exported function in this file should read this well."
🤖 This review was automatically generated with Coder Agents.
Resolve
X-Forwarded-Forby walking the chain right-to-left and selecting the rightmost entry that is not a trusted origin (the real client), falling back to the leftmost entry only when every parsed hop is a trusted origin.