Skip to content

Commit 1863339

Browse files
ClientIPFromXFF: unify both XFF walkers behind a single walkXFF primitive
After the prior two commits, both XFF middlewares had near-identical right-to-left scan loops differing only in stop condition (first non-trusted entry vs Nth non-empty entry from the right). nthFromRightXFF and rightmostUntrustedXFF together carried ~30 lines of duplicated loop boilerplate. Collapse to one primitive: walkXFF(headers []string, visit func(entry string) bool) iterates the merged X-Forwarded-For chain right-to-left and calls visit on each trimmed non-empty entry; visit returns true to stop the walk. Both middleware handlers now share this primitive and supply their own stop condition as a small inline visitor closure (~7 lines each). Net: -21 lines in middleware/client_ip.go, one fewer concept to reason about, single source of truth for the (security-critical) walk order and the empty-entry drop rule. Zero-allocation property is preserved -- benchmarked side-by-side against the previous two-helpers implementation: BenchmarkRightmost_TwoHelpers 36 ns/op 0 B/op 0 allocs/op BenchmarkRightmost_Callback 37 ns/op 0 B/op 0 allocs/op BenchmarkNth_TwoHelpers 11 ns/op 0 B/op 0 allocs/op BenchmarkNth_Callback 13 ns/op 0 B/op 0 allocs/op Go's escape analysis keeps the visitor closures (and their captured &found / &entry / &n locals) on the stack; only cost is one indirect call per visited entry (~1-2 ns). All tests still pass (advisory pins, fail-closed pins, multi-header merge, normalization, boundary CIDR, last-write-wins, panic conditions). Stale comments in client_ip_test.go that referenced the removed helpers by name are updated; test names/comments uniformly use "position" (matching the godoc) for the algorithmic concept and "entry" for an individual chain value. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7251b44 commit 1863339

2 files changed

Lines changed: 52 additions & 75 deletions

File tree

middleware/client_ip.go

Lines changed: 37 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,20 @@ func ClientIPFromXFF(trustedIPPrefixes ...string) func(http.Handler) http.Handle
7979
}
8080
return func(h http.Handler) http.Handler {
8181
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82-
if ip, ok := rightmostUntrustedXFF(r.Header.Values(xForwardedForHeader), prefixes); ok {
83-
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip))
82+
var found netip.Addr
83+
walkXFF(r.Header.Values(xForwardedForHeader), func(v string) bool {
84+
ip, ok := parseHeaderAddr(v)
85+
if !ok {
86+
return true // fail-closed; leave found unset
87+
}
88+
if inAnyPrefix(ip, prefixes) {
89+
return false // trusted hop; keep walking left
90+
}
91+
found = ip
92+
return true
93+
})
94+
if found.IsValid() {
95+
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, found))
8496
}
8597
h.ServeHTTP(w, r)
8698
})
@@ -119,8 +131,18 @@ func ClientIPFromXFFTrustedProxies(numTrustedProxies int) func(http.Handler) htt
119131
}
120132
return func(h http.Handler) http.Handler {
121133
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122-
if v := nthFromRightXFF(r.Header.Values(xForwardedForHeader), numTrustedProxies); v != "" {
123-
if ip, ok := parseHeaderAddr(v); ok {
134+
n := numTrustedProxies
135+
var entry string
136+
walkXFF(r.Header.Values(xForwardedForHeader), func(v string) bool {
137+
n--
138+
if n == 0 {
139+
entry = v
140+
return true
141+
}
142+
return false
143+
})
144+
if entry != "" {
145+
if ip, ok := parseHeaderAddr(entry); ok {
124146
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip))
125147
}
126148
}
@@ -174,19 +196,15 @@ func GetClientIPAddr(ctx context.Context) netip.Addr {
174196
return ip
175197
}
176198

177-
// nthFromRightXFF returns the trimmed entry at position n from the right
178-
// end of the merged X-Forwarded-For chain (n=1 is the rightmost), or "" if
179-
// the chain has fewer than n non-empty entries.
180-
//
181-
// Empty/whitespace entries are dropped and do NOT count toward n, so an
182-
// attacker can't slide their chosen value into the trusted slot by
183-
// prepending commas.
184-
//
185-
// Multi-header merge is required for security per RFC 2616: multiple XFF
186-
// headers MUST be processed in order received; reading only the first or
187-
// last lets an attacker pick which value security logic sees by sending
188-
// duplicate headers. Lazy walk, zero allocations.
189-
func nthFromRightXFF(headers []string, n int) string {
199+
// walkXFF walks the entries of the merged X-Forwarded-For chain
200+
// RIGHT-TO-LEFT, invoking visit on each trimmed non-empty entry. visit
201+
// returns true to stop the walk. Lazy walk, zero allocations (entries
202+
// are substrings of the input headers).
203+
//
204+
// Multiple XFF headers are merged per RFC 2616 — each header's
205+
// comma-separated entries in order received — so an attacker cannot pick
206+
// which value security logic sees by sending a duplicate header.
207+
func walkXFF(headers []string, visit func(entry string) bool) {
190208
for hi := len(headers) - 1; hi >= 0; hi-- {
191209
h := headers[hi]
192210
for h != "" {
@@ -200,52 +218,11 @@ func nthFromRightXFF(headers []string, n int) string {
200218
if v == "" {
201219
continue
202220
}
203-
n--
204-
if n == 0 {
205-
return v
206-
}
207-
}
208-
}
209-
return ""
210-
}
211-
212-
// rightmostUntrustedXFF walks all X-Forwarded-For header values right-to-left
213-
// across the merged chain, skipping trusted-prefix and empty entries, and
214-
// returns the first remaining valid IP. An unparseable entry encountered
215-
// mid-walk fails closed: the walk is abandoned and no IP is returned, since
216-
// an unparseable hop is indistinguishable from a hostile or missing one and
217-
// we cannot safely keep walking past it.
218-
//
219-
// Walks the headers lazily so the common case — one trusted hop, rightmost
220-
// entry is the client — returns on the very first iteration without
221-
// materializing the full chain. Zero allocations beyond what
222-
// [netip.ParseAddr] does internally. See also [nthFromRightXFF], which uses
223-
// the same iteration shape with a count-down stop condition.
224-
func rightmostUntrustedXFF(headers []string, trustedPrefixes []netip.Prefix) (netip.Addr, bool) {
225-
for hi := len(headers) - 1; hi >= 0; hi-- {
226-
h := headers[hi]
227-
for h != "" {
228-
var v string
229-
if i := strings.LastIndexByte(h, ','); i >= 0 {
230-
v, h = h[i+1:], h[:i]
231-
} else {
232-
v, h = h, ""
233-
}
234-
v = strings.TrimSpace(v)
235-
if v == "" {
236-
continue
237-
}
238-
ip, ok := parseHeaderAddr(v)
239-
if !ok {
240-
return netip.Addr{}, false
241-
}
242-
if inAnyPrefix(ip, trustedPrefixes) {
243-
continue
221+
if visit(v) {
222+
return
244223
}
245-
return ip, true
246224
}
247225
}
248-
return netip.Addr{}, false
249226
}
250227

251228
// inAnyPrefix reports whether ip falls within any of the given prefixes.

middleware/client_ip_test.go

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -200,25 +200,25 @@ func TestClientIPFromXFFTrustedProxies(t *testing.T) {
200200
{"shorter_than_n", 3, []string{"1.1.1.1, 2.2.2.2"}, ""},
201201
{"missing_header", 1, nil, ""},
202202

203-
// Spoofing: prepended attacker values are to the LEFT of the chosen slot,
204-
// so they're ignored.
203+
// Spoofing: prepended attacker values are to the LEFT of the chosen
204+
// position, so they're ignored.
205205
{"spoof_prepend_ignored", 2, []string{"6.6.6.6, 1.1.1.1, 2.2.2.2, 3.3.3.3"}, "2.2.2.2"},
206206

207-
// Bad parse at the target slot: no IP set (don't accept garbage as client IP).
208-
{"bad_parse_at_slot", 2, []string{"garbage, 2.2.2.2"}, ""},
207+
// Bad parse at the target position: no IP set (don't accept garbage as client IP).
208+
{"bad_parse_at_position", 2, []string{"garbage, 2.2.2.2"}, ""},
209209

210210
// Merging across multiple header instances.
211211
{"multi_header", 2, []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"}, "2.2.2.2"},
212212

213-
// Empty entries dropped by mergeXFF must not shift the slot index. The
214-
// slot is computed from the right, where trusted proxies append, so an
215-
// attacker prepending commas can't slide their chosen value into the
216-
// trusted slot.
217-
{"empties_dont_shift_slot", 2, []string{",,, 3.3.3.3, 1.1.1.1, 2.2.2.2"}, "1.1.1.1"},
213+
// Empty entries dropped by the walker must not shift the chosen
214+
// position. The position is computed from the right, where trusted
215+
// proxies append, so an attacker prepending commas can't slide their
216+
// chosen value into the trusted position.
217+
{"empties_dont_shift_position", 2, []string{",,, 3.3.3.3, 1.1.1.1, 2.2.2.2"}, "1.1.1.1"},
218218

219-
// Normalization at the chosen slot: v4-mapped IPv6 folds to v4, zone stripped.
220-
{"v4_mapped_at_slot_folded", 1, []string{"::ffff:1.2.3.4"}, "1.2.3.4"},
221-
{"zone_stripped_at_slot", 1, []string{"2001:db8::1%eth0"}, "2001:db8::1"},
219+
// Normalization at the chosen position: v4-mapped IPv6 folds to v4, zone stripped.
220+
{"v4_mapped_at_position_folded", 1, []string{"::ffff:1.2.3.4"}, "1.2.3.4"},
221+
{"zone_stripped_at_position", 1, []string{"2001:db8::1%eth0"}, "2001:db8::1"},
222222
}
223223
for _, tc := range tt {
224224
t.Run(tc.name, func(t *testing.T) {
@@ -487,8 +487,8 @@ func TestXFF_MultipleHeadersMerged(t *testing.T) {
487487
}
488488
}
489489

490-
// TestXFF_V4MappedIPv6BypassesTrustedV4Prefix demonstrates a gap in
491-
// rightmostUntrustedXFF: netip.Prefix.Contains returns false when an
490+
// TestXFF_V4MappedIPv6BypassesTrustedV4Prefix demonstrates a gap in the
491+
// rightmost-untrusted walker: netip.Prefix.Contains returns false when an
492492
// IPv4-mapped IPv6 address (::ffff:a.b.c.d) is checked against a plain v4
493493
// prefix. So an attacker who can inject the v4-mapped form of an internal
494494
// address into XFF — and whose own connecting IP happens to be in the
@@ -596,7 +596,7 @@ func TestClientIPFromRemoteAddr_V4MappedIsUnmapped(t *testing.T) {
596596
// by trim before parsing, and do not trip fail-closed.
597597
//
598598
// These tests are EXPECTED TO FAIL until the fail-closed change lands in
599-
// rightmostUntrustedXFF.
599+
// the ClientIPFromXFF walker.
600600
func TestXFF_FailClosedOnUnparseable(t *testing.T) {
601601
tt := []struct {
602602
name string

0 commit comments

Comments
 (0)