Skip to content

fix: resolve client IP from the rightmost untrusted X-Forwarded-For entry#26646

Open
sreya wants to merge 6 commits into
mainfrom
x-forwarded-for
Open

fix: resolve client IP from the rightmost untrusted X-Forwarded-For entry#26646
sreya wants to merge 6 commits into
mainfrom
x-forwarded-for

Conversation

@sreya

@sreya sreya commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Resolve X-Forwarded-For by 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.

sreya added 3 commits June 23, 2026 22:54
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.
@sreya sreya requested a review from geokat June 24, 2026 00:38
@sreya sreya removed the request for review from geokat June 24, 2026 00:38
@sreya

sreya commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-06-24 00:38 UTC by @sreya
Spend: $20.70 / $100.00

Review history
  • R1 (2026-06-24): 14 reviewers, 3 Nit, 3 P3, COMMENT. Review

deep-review v0.9.0 | Round 1 | 51591e3..3283dd7

Last posted: Round 1, 6 findings (3 P3, 3 Nit), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Open realip_test.go:754 No test for all-trusted-origins fallback path in extractForwardedAddress R1 Netero Yes
CRF-2 P3 Open realip.go:198 getRemoteAddress comment perpetuates "first value is client address" vulnerability assumption R1 Leorio P3, Ryosuke Nit, Robin Nit, Meruem Nit, Gon Note Yes
CRF-3 P3 Open realip.go:113 FilterUntrustedOriginHeaders collapses multi-line XFF to first (client-controlled) line R1 Meruem P3, Kurapika Note Yes
CRF-4 Nit Open realip_test.go:824 Redundant require.NotEqual after Equal on same value R1 Bisky Yes
CRF-5 Nit Open realip_test.go:758 spoofedAddr/realClient use different naming axes (type vs role) R1 Gon Yes
CRF-6 Nit Open realip_test.go:815 "Direct call to ExtractRealIPAddress" is pure code restatement R1 Gon Yes
CRF-7 P4 Dropped by orchestrator (accurate security documentation; Leorio praised extractForwardedAddress doc as exemplary; comments explain RFC and attack model for future readers) multiple Comment verbosity pattern across production and test code R1 Gon No
CRF-8 P4 Dropped by orchestrator (edge case requires non-standard header behavior; all existing non-XFF tests pass) realip.go:83 No test for non-XFF header with comma-separated value R1 Bisky No

Contested and acknowledged

(none)

Round log

Round 1

Panel (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.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread coderd/httpmw/realip_test.go Outdated
// arbitrary values, while trusted proxies append the real client to the right.
// The resolved address must therefore be the rightmost entry that is not a
// trusted origin, not the leftmost (client-supplied) entry.
func TestExtractRealIPSpoofedXForwardedFor(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-1] Missing test for the all-trusted-origins fallback path in extractForwardedAddress.

The doc comment (realip.go:225) states: "If every parsed address is a trusted origin, the leftmost address is returned." All three test cases include at least one untrusted address (realClient), so the leftmost fallback at line 242 is never exercised.

A case like XFF 10.0.0.1, 10.0.0.2 with 10.0.0.0/8 trusted would cover the fallback and lock down the contract.

(Netero)

🤖

Comment thread coderd/httpmw/realip_test.go Outdated
require.NoError(t, err)
require.Equal(t, realClient, addr.String(),
"must use the real client IP, not the spoofed leftmost value")
require.NotEqual(t, spoofedAddr, addr.String(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-4] require.NotEqual(t, spoofedAddr, addr.String()) is redundant after require.Equal(t, realClient, addr.String()) on line 822. Since realClient != spoofedAddr, the NotEqual can never fail independently. Reads as intent documentation rather than a test; removing it loses no coverage.

(Bisky)

🤖

Comment thread coderd/httpmw/realip_test.go Outdated
t.Parallel()

const (
spoofedAddr = "1.2.3.4"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-5] spoofedAddr names the data type (an address); realClient names the role (a client). Both are IP address strings. Using one naming axis would be clearer: spoofedAddr/realClientAddr or spoofedIP/realClientIP.

(Gon)

🤖

Comment thread coderd/httpmw/realip_test.go Outdated
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

// Direct call to ExtractRealIPAddress.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-6] "Direct call to ExtractRealIPAddress." The next line is httpmw.ExtractRealIPAddress(newConfig(), req). Pure restatement; the comment adds nothing the code doesn't show. Delete or replace with what makes this section distinct from the middleware section below (e.g., tests the return value vs. the RemoteAddr rewrite).

(Gon)

🤖

sreya added 3 commits June 24, 2026 17:19
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant