From c685865ebb1d30dcca576c866ab9c197a77caa19 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 23 Jun 2026 22:54:57 +0000 Subject: [PATCH 1/7] test(coderd/httpmw): add regression test for X-Forwarded-For IP spoofing 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. --- coderd/httpmw/realip_test.go | 83 ++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index caa1fe98496..82044e4f1ff 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -744,3 +744,86 @@ func TestApplicationProxy(t *testing.T) { }) } } + +// TestExtractRealIPSpoofedXForwardedFor is a regression test for an IP-spoofing +// vulnerability. When a request arrives from a trusted proxy, X-Forwarded-For was +// parsed left-to-right, so the leftmost (client-controlled) value was accepted as +// the real IP. Because reverse proxies append the peer that connected to them, the +// leftmost value is the part a client can forge. An attacker could prepend an +// arbitrary address to X-Forwarded-For to spoof the IP used for per-IP login rate +// limiting and audit log source addresses. The real client is the rightmost address +// that is not a trusted origin. +func TestExtractRealIPSpoofedXForwardedFor(t *testing.T) { + t.Parallel() + + const ( + spoofedAddr = "1.2.3.4" + realClient = "203.0.113.5" + ) + + // Trust a realistic proxy range (not 0.0.0.0/0) so that a spoofed + // public-client address falls outside the trusted set. + newConfig := func() *httpmw.RealIPConfig { + return &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{"X-Forwarded-For"}, + } + } + + cases := []struct { + name string + xForwardedFor string + }{ + { + // A single trusted proxy appends the real client to whatever the + // client claimed, so the leftmost value is attacker-controlled. + name: "single-proxy", + xForwardedFor: spoofedAddr + ", " + realClient, + }, + { + // A chain of trusted proxies appends each hop. The rightmost + // untrusted address (the real client) must win, skipping the + // trusted inner-proxy hop. + name: "chained-proxies", + xForwardedFor: spoofedAddr + ", " + realClient + ", 10.0.0.2", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Direct call to ExtractRealIPAddress. + req := httptest.NewRequest(http.MethodGet, "http://localhost", nil) + req.RemoteAddr = "10.0.0.1" // trusted proxy peer + req.Header.Set("X-Forwarded-For", tc.xForwardedFor) + + addr, err := httpmw.ExtractRealIPAddress(newConfig(), req) + 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(), + "spoofed leftmost X-Forwarded-For value must be ignored") + + // Middleware rewrites RemoteAddr to the same value that + // httprate.KeyByIP (rate limiting) and audit.InitRequest consume. + mwReq := httptest.NewRequest(http.MethodGet, "http://localhost", nil) + mwReq.RemoteAddr = "10.0.0.1" + mwReq.Header.Set("X-Forwarded-For", tc.xForwardedFor) + + handlerCalled := false + next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + handlerCalled = true + require.Equal(t, realClient, r.RemoteAddr, + "middleware must set RemoteAddr to the real client IP") + }) + httpmw.ExtractRealIP(newConfig())(next).ServeHTTP(httptest.NewRecorder(), mwReq) + require.True(t, handlerCalled, "expected handler to be invoked") + }) + } +} From 4fc938fdb22507d23518a5b4b1fbf791ad9678fa Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 23 Jun 2026 22:56:23 +0000 Subject: [PATCH 2/7] fix(coderd/httpmw): use rightmost untrusted X-Forwarded-For address 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. --- coderd/httpmw/realip.go | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/coderd/httpmw/realip.go b/coderd/httpmw/realip.go index f428e15fcf4..1886338b4d1 100644 --- a/coderd/httpmw/realip.go +++ b/coderd/httpmw/realip.go @@ -70,7 +70,7 @@ func ExtractRealIPAddress(config *RealIPConfig, req *http.Request) (net.IP, erro } for _, trustedHeader := range config.TrustedHeaders { - addr := getRemoteAddress(req.Header.Get(trustedHeader)) + addr := extractForwardedAddress(config, req.Header.Get(trustedHeader)) if addr != nil { return addr, nil } @@ -206,6 +206,31 @@ func getRemoteAddress(address string) net.IP { return net.ParseIP(host) } +// extractForwardedAddress parses a comma-separated forwarding header value and +// returns the rightmost address that is not a trusted origin. Reverse proxies +// append the peer that connected to them, so the rightmost untrusted address is +// the real client; any values a client prepends to spoof its address sit to the +// left of the addresses inserted by trusted proxies and are ignored. If every +// parsed address is a trusted origin, the leftmost address is returned. It +// returns nil when no address can be parsed. +func extractForwardedAddress(config *RealIPConfig, value string) net.IP { + parts := strings.Split(value, ",") + var leftmost net.IP + for i := len(parts) - 1; i >= 0; i-- { + ip := getRemoteAddress(strings.TrimSpace(parts[i])) + if ip == nil { + continue + } + // Iterating right-to-left, so the last assignment is the leftmost + // valid address, used as the fallback when all hops are trusted. + leftmost = ip + if !isContainedIn(config.TrustedOrigins, ip) { + return ip + } + } + return leftmost +} + // isContainedIn checks that the given address is contained in the given // network. func isContainedIn(networks []*net.IPNet, address net.IP) bool { From 3283dd7fb9af96c1b1a40bb757a1348982f86dc4 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 23 Jun 2026 23:16:10 +0000 Subject: [PATCH 3/7] fix(coderd/httpmw): join multiple X-Forwarded-For header lines 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. --- coderd/httpmw/realip.go | 23 ++++++++++++++------ coderd/httpmw/realip_test.go | 42 ++++++++++++++++++++++++------------ 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/coderd/httpmw/realip.go b/coderd/httpmw/realip.go index 1886338b4d1..487815a2c29 100644 --- a/coderd/httpmw/realip.go +++ b/coderd/httpmw/realip.go @@ -70,7 +70,17 @@ func ExtractRealIPAddress(config *RealIPConfig, req *http.Request) (net.IP, erro } for _, trustedHeader := range config.TrustedHeaders { - addr := extractForwardedAddress(config, req.Header.Get(trustedHeader)) + // X-Forwarded-For is a list-valued header. Per RFC 7230, multiple + // field lines with the same name are equivalent to a single + // comma-separated value. Join them so a client cannot hide a spoofed + // address in the first field line, which Header.Get would return on + // its own. Other forwarding headers carry a single edge-proxy value, + // so use Header.Get to preserve their first-value semantics. + value := req.Header.Get(trustedHeader) + if http.CanonicalHeaderKey(trustedHeader) == headerXForwardedFor { + value = strings.Join(req.Header.Values(trustedHeader), ",") + } + addr := extractForwardedAddress(config, value) if addr != nil { return addr, nil } @@ -208,11 +218,12 @@ func getRemoteAddress(address string) net.IP { // extractForwardedAddress parses a comma-separated forwarding header value and // returns the rightmost address that is not a trusted origin. Reverse proxies -// append the peer that connected to them, so the rightmost untrusted address is -// the real client; any values a client prepends to spoof its address sit to the -// left of the addresses inserted by trusted proxies and are ignored. If every -// parsed address is a trusted origin, the leftmost address is returned. It -// returns nil when no address can be parsed. +// append the peer that connected to them, so when every trusted proxy hop is +// listed in TrustedOrigins, the rightmost untrusted address is the real client; +// any values a client prepends to spoof its address sit to the left of the +// addresses inserted by trusted proxies and are ignored. If every parsed address +// is a trusted origin, the leftmost address is returned. It returns nil when no +// address can be parsed. func extractForwardedAddress(config *RealIPConfig, value string) net.IP { parts := strings.Split(value, ",") var leftmost net.IP diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index 82044e4f1ff..81112de7a3f 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -745,14 +745,12 @@ func TestApplicationProxy(t *testing.T) { } } -// TestExtractRealIPSpoofedXForwardedFor is a regression test for an IP-spoofing -// vulnerability. When a request arrives from a trusted proxy, X-Forwarded-For was -// parsed left-to-right, so the leftmost (client-controlled) value was accepted as -// the real IP. Because reverse proxies append the peer that connected to them, the -// leftmost value is the part a client can forge. An attacker could prepend an -// arbitrary address to X-Forwarded-For to spoof the IP used for per-IP login rate -// limiting and audit log source addresses. The real client is the rightmost address -// that is not a trusted origin. +// TestExtractRealIPSpoofedXForwardedFor verifies that a client cannot control +// the resolved real IP by setting X-Forwarded-For. Reverse proxies append the +// peer that connected to them, so a client can set the leftmost entries to +// 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) { t.Parallel() @@ -776,22 +774,38 @@ func TestExtractRealIPSpoofedXForwardedFor(t *testing.T) { } cases := []struct { - name string - xForwardedFor string + name string + // xForwardedFor holds one entry per physical X-Forwarded-For header + // line. Multiple entries exercise the case where a proxy appends its + // hop as a separate header field rather than to the existing value. + xForwardedFor []string }{ { // A single trusted proxy appends the real client to whatever the // client claimed, so the leftmost value is attacker-controlled. name: "single-proxy", - xForwardedFor: spoofedAddr + ", " + realClient, + xForwardedFor: []string{spoofedAddr + ", " + realClient}, }, { // A chain of trusted proxies appends each hop. The rightmost // untrusted address (the real client) must win, skipping the // trusted inner-proxy hop. name: "chained-proxies", - xForwardedFor: spoofedAddr + ", " + realClient + ", 10.0.0.2", + xForwardedFor: []string{spoofedAddr + ", " + realClient + ", 10.0.0.2"}, }, + { + // A proxy may append its hop as a separate header line. Per + // RFC 7230 these are equivalent to a single comma-joined value, + // so the spoofed first line must not be trusted on its own. + name: "multiple-header-lines", + xForwardedFor: []string{spoofedAddr, realClient + ", 10.0.0.2"}, + }, + } + + setXFF := func(req *http.Request, lines []string) { + // Assign directly to preserve multiple header lines; Header.Set would + // collapse them into a single value. + req.Header["X-Forwarded-For"] = lines } for _, tc := range cases { @@ -801,7 +815,7 @@ func TestExtractRealIPSpoofedXForwardedFor(t *testing.T) { // Direct call to ExtractRealIPAddress. req := httptest.NewRequest(http.MethodGet, "http://localhost", nil) req.RemoteAddr = "10.0.0.1" // trusted proxy peer - req.Header.Set("X-Forwarded-For", tc.xForwardedFor) + setXFF(req, tc.xForwardedFor) addr, err := httpmw.ExtractRealIPAddress(newConfig(), req) require.NoError(t, err) @@ -814,7 +828,7 @@ func TestExtractRealIPSpoofedXForwardedFor(t *testing.T) { // httprate.KeyByIP (rate limiting) and audit.InitRequest consume. mwReq := httptest.NewRequest(http.MethodGet, "http://localhost", nil) mwReq.RemoteAddr = "10.0.0.1" - mwReq.Header.Set("X-Forwarded-For", tc.xForwardedFor) + setXFF(mwReq, tc.xForwardedFor) handlerCalled := false next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { From 5842b6e752ec4c37b8f363d1201c696ade605882 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 24 Jun 2026 17:19:41 +0000 Subject: [PATCH 4/7] test(coderd/httpmw): fold X-Forwarded-For spoof cases into TestExtractAddress 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. --- coderd/httpmw/realip_test.go | 167 +++++++++++++++-------------------- 1 file changed, 70 insertions(+), 97 deletions(-) diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index 81112de7a3f..c88842705e4 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -82,6 +82,76 @@ func TestExtractAddress(t *testing.T) { }, ExpectedRemoteAddr: "10.24.1.1", }, + { + // Reverse proxies append the peer that connected to them, so a + // client controls the leftmost X-Forwarded-For entries while a + // trusted proxy appends the real client to the right. With a + // realistic proxy CIDR (not 0.0.0.0/0), the resolved address must + // be the rightmost entry outside the trusted set, never the + // leftmost client-supplied value. + Name: "spoofed-x-forwarded-for-single-proxy", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + RemoteAddr: "10.0.0.1", + Header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4, 203.0.113.5"}, + }, + ExpectedRemoteAddr: "203.0.113.5", + }, + { + // A chain of trusted proxies appends each hop. The rightmost + // untrusted address (the real client) wins, skipping the trusted + // inner-proxy hop. + Name: "spoofed-x-forwarded-for-chained-proxies", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + RemoteAddr: "10.0.0.1", + Header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4, 203.0.113.5, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "203.0.113.5", + }, + { + // A proxy may append its hop as a separate header line. Per + // RFC 7230 section 3.2.2 these are equivalent to a single + // comma-joined value, so the spoofed first line must not be + // trusted on its own. + Name: "spoofed-x-forwarded-for-multiple-lines", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + RemoteAddr: "10.0.0.1", + Header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4", "203.0.113.5, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "203.0.113.5", + }, { Name: "single-real-ip", Config: &httpmw.RealIPConfig{ @@ -744,100 +814,3 @@ func TestApplicationProxy(t *testing.T) { }) } } - -// TestExtractRealIPSpoofedXForwardedFor verifies that a client cannot control -// the resolved real IP by setting X-Forwarded-For. Reverse proxies append the -// peer that connected to them, so a client can set the leftmost entries to -// 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) { - t.Parallel() - - const ( - spoofedAddr = "1.2.3.4" - realClient = "203.0.113.5" - ) - - // Trust a realistic proxy range (not 0.0.0.0/0) so that a spoofed - // public-client address falls outside the trusted set. - newConfig := func() *httpmw.RealIPConfig { - return &httpmw.RealIPConfig{ - TrustedOrigins: []*net.IPNet{ - { - IP: net.ParseIP("10.0.0.0"), - Mask: net.CIDRMask(8, 32), - }, - }, - TrustedHeaders: []string{"X-Forwarded-For"}, - } - } - - cases := []struct { - name string - // xForwardedFor holds one entry per physical X-Forwarded-For header - // line. Multiple entries exercise the case where a proxy appends its - // hop as a separate header field rather than to the existing value. - xForwardedFor []string - }{ - { - // A single trusted proxy appends the real client to whatever the - // client claimed, so the leftmost value is attacker-controlled. - name: "single-proxy", - xForwardedFor: []string{spoofedAddr + ", " + realClient}, - }, - { - // A chain of trusted proxies appends each hop. The rightmost - // untrusted address (the real client) must win, skipping the - // trusted inner-proxy hop. - name: "chained-proxies", - xForwardedFor: []string{spoofedAddr + ", " + realClient + ", 10.0.0.2"}, - }, - { - // A proxy may append its hop as a separate header line. Per - // RFC 7230 these are equivalent to a single comma-joined value, - // so the spoofed first line must not be trusted on its own. - name: "multiple-header-lines", - xForwardedFor: []string{spoofedAddr, realClient + ", 10.0.0.2"}, - }, - } - - setXFF := func(req *http.Request, lines []string) { - // Assign directly to preserve multiple header lines; Header.Set would - // collapse them into a single value. - req.Header["X-Forwarded-For"] = lines - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Direct call to ExtractRealIPAddress. - req := httptest.NewRequest(http.MethodGet, "http://localhost", nil) - req.RemoteAddr = "10.0.0.1" // trusted proxy peer - setXFF(req, tc.xForwardedFor) - - addr, err := httpmw.ExtractRealIPAddress(newConfig(), req) - 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(), - "spoofed leftmost X-Forwarded-For value must be ignored") - - // Middleware rewrites RemoteAddr to the same value that - // httprate.KeyByIP (rate limiting) and audit.InitRequest consume. - mwReq := httptest.NewRequest(http.MethodGet, "http://localhost", nil) - mwReq.RemoteAddr = "10.0.0.1" - setXFF(mwReq, tc.xForwardedFor) - - handlerCalled := false - next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { - handlerCalled = true - require.Equal(t, realClient, r.RemoteAddr, - "middleware must set RemoteAddr to the real client IP") - }) - httpmw.ExtractRealIP(newConfig())(next).ServeHTTP(httptest.NewRecorder(), mwReq) - require.True(t, handlerCalled, "expected handler to be invoked") - }) - } -} From faca70af29e3b824124b6b746004d985222c7486 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 24 Jun 2026 17:26:57 +0000 Subject: [PATCH 5/7] test(coderd/httpmw): clarify X-Forwarded-For spoof test cases 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 --- coderd/httpmw/realip_test.go | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index c88842705e4..fedf96fca27 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -83,27 +83,15 @@ func TestExtractAddress(t *testing.T) { ExpectedRemoteAddr: "10.24.1.1", }, { - // Reverse proxies append the peer that connected to them, so a - // client controls the leftmost X-Forwarded-For entries while a - // trusted proxy appends the real client to the right. With a - // realistic proxy CIDR (not 0.0.0.0/0), the resolved address must - // be the rightmost entry outside the trusted set, never the - // leftmost client-supplied value. - Name: "spoofed-x-forwarded-for-single-proxy", + Name: "no-trusted-origins", Config: &httpmw.RealIPConfig{ - TrustedOrigins: []*net.IPNet{ - { - IP: net.ParseIP("10.0.0.0"), - Mask: net.CIDRMask(8, 32), - }, - }, TrustedHeaders: []string{ "X-Forwarded-For", }, }, - RemoteAddr: "10.0.0.1", + RemoteAddr: "203.0.113.5", Header: http.Header{ - "X-Forwarded-For": []string{"1.2.3.4, 203.0.113.5"}, + "X-Forwarded-For": []string{"1.2.3.4"}, }, ExpectedRemoteAddr: "203.0.113.5", }, @@ -111,7 +99,7 @@ func TestExtractAddress(t *testing.T) { // A chain of trusted proxies appends each hop. The rightmost // untrusted address (the real client) wins, skipping the trusted // inner-proxy hop. - Name: "spoofed-x-forwarded-for-chained-proxies", + Name: "picks-rightmost-untrusted", Config: &httpmw.RealIPConfig{ TrustedOrigins: []*net.IPNet{ { @@ -134,7 +122,7 @@ func TestExtractAddress(t *testing.T) { // RFC 7230 section 3.2.2 these are equivalent to a single // comma-joined value, so the spoofed first line must not be // trusted on its own. - Name: "spoofed-x-forwarded-for-multiple-lines", + Name: "x-forwarded-for-set-multiple-times", Config: &httpmw.RealIPConfig{ TrustedOrigins: []*net.IPNet{ { From 9ca7a1f01509d823d64f1f56bcd820626b7fbfdf Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 24 Jun 2026 17:31:40 +0000 Subject: [PATCH 6/7] test(coderd/httpmw): remove redundant no-trusted-origins spoof case The no-trusted-origins behavior is already covered by default-empty-config. --- coderd/httpmw/realip_test.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index fedf96fca27..eee241d1cd8 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -82,19 +82,6 @@ func TestExtractAddress(t *testing.T) { }, ExpectedRemoteAddr: "10.24.1.1", }, - { - Name: "no-trusted-origins", - Config: &httpmw.RealIPConfig{ - TrustedHeaders: []string{ - "X-Forwarded-For", - }, - }, - RemoteAddr: "203.0.113.5", - Header: http.Header{ - "X-Forwarded-For": []string{"1.2.3.4"}, - }, - ExpectedRemoteAddr: "203.0.113.5", - }, { // A chain of trusted proxies appends each hop. The rightmost // untrusted address (the real client) wins, skipping the trusted From d49a8e0b333baf2094225f41ccefdc6a0339513f Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 24 Jun 2026 18:03:49 +0000 Subject: [PATCH 7/7] fix(coderd/httpmw): harden header filtering and clarify IP parsing docs 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. --- coderd/httpmw/realip.go | 21 ++++++++++++---- coderd/httpmw/realip_test.go | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/coderd/httpmw/realip.go b/coderd/httpmw/realip.go index 487815a2c29..b3e460b9e9d 100644 --- a/coderd/httpmw/realip.go +++ b/coderd/httpmw/realip.go @@ -111,6 +111,14 @@ func FilterUntrustedOriginHeaders(config *RealIPConfig, req *http.Request) { } for _, header := range config.TrustedHeaders { + // X-Forwarded-For is a list-valued header whose field lines are + // equivalent to a single comma-separated value (RFC 7230 section + // 3.2.2). Join them so later hops are not dropped when collapsing to a + // single line. Other forwarding headers carry a single value. + if http.CanonicalHeaderKey(header) == headerXForwardedFor { + req.Header.Set(header, strings.Join(req.Header.Values(header), ",")) + continue + } req.Header.Set(header, req.Header.Get(header)) } } @@ -195,12 +203,15 @@ func EnsureXForwardedForHeader(req *http.Request) error { return nil } -// getRemoteAddress extracts the IP address from the given string. If -// the string contains commas, it assumes that the first part is the -// original address. +// getRemoteAddress extracts a single IP address from the given string, +// stripping a port if present. If the string contains commas, only the +// portion before the first comma is parsed. This helper does not select the +// real client from a multi-hop X-Forwarded-For chain; use +// extractForwardedAddress for that, which accounts for client-supplied values. func getRemoteAddress(address string) net.IP { - // X-Forwarded-For may contain multiple addresses, in case the - // proxies are chained; the first value is the client address + // A value may contain a port and, for a raw X-Forwarded-For value, more + // than one comma-separated address. Parse only the part before the first + // comma. i := strings.IndexByte(address, ',') if i == -1 { i = len(address) diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index eee241d1cd8..cce7445bf68 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -104,6 +104,27 @@ func TestExtractAddress(t *testing.T) { }, ExpectedRemoteAddr: "203.0.113.5", }, + { + // When every parsed hop is a trusted origin, there is no untrusted + // client to select, so the leftmost address is used. + Name: "all-trusted-falls-back-to-leftmost", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + RemoteAddr: "10.0.0.1", + Header: http.Header{ + "X-Forwarded-For": []string{"10.0.0.1, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "10.0.0.1", + }, { // A proxy may append its hop as a separate header line. Per // RFC 7230 section 3.2.2 these are equivalent to a single @@ -501,6 +522,31 @@ func TestFilterUntrusted(t *testing.T) { }, ExpectedRemoteAddr: "1.2.3.4", }, + { + // For a trusted origin, multiple X-Forwarded-For field lines are + // joined into one comma-separated value rather than collapsed to + // the first line, so later hops are preserved. + Name: "trusted-origin-joins-multiple-x-forwarded-for", + Config: &httpmw.RealIPConfig{ + TrustedOrigins: []*net.IPNet{ + { + IP: net.ParseIP("10.0.0.0"), + Mask: net.CIDRMask(8, 32), + }, + }, + TrustedHeaders: []string{ + "X-Forwarded-For", + }, + }, + Header: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4", "203.0.113.5, 10.0.0.2"}, + }, + RemoteAddr: "10.0.0.1", + ExpectedHeader: http.Header{ + "X-Forwarded-For": []string{"1.2.3.4,203.0.113.5, 10.0.0.2"}, + }, + ExpectedRemoteAddr: "10.0.0.1", + }, } for _, test := range tests {