Skip to content

Commit 6133e51

Browse files
VojtechVitekclaude
andcommitted
docs: deployment recipe + verify checklist for ClientIPFromXFFTrustedProxies
Replace the counting prose and ASCII diagram with a recipe table mapping common deployments to numTrustedProxies values, plus a "verify with a known IP" step. Steer users toward ClientIPFromXFF with explicit CIDRs (added pointers to CDN-published IP lists from Cloudflare, AWS, Fastly, GCP) since CIDR-based trust cannot off-by-one. Add one fail-closed test case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3b17157 commit 6133e51

4 files changed

Lines changed: 69 additions & 22 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,11 @@ is folded to plain IPv4, and IPv6 zone identifiers carried in headers are
464464
stripped, so one logical client maps to a single canonical key for logs,
465465
rate limits, and ACLs.
466466

467+
For `ClientIPFromXFFTrustedProxies`, `numTrustedProxies` is the total proxy
468+
hops between client and server. Prefer `ClientIPFromXFF` with explicit CIDRs
469+
when you can — CIDR-based trust cannot off-by-one. See the godoc for a
470+
deployment recipe and a verify checklist.
471+
467472
See the per-function godoc for the full semantics of each middleware, and
468473
[adam-p's "The perils of the 'real' client IP"](https://adam-p.ca/blog/2022/03/x-forwarded-for/)
469474
for the underlying threat model.

middleware/client_ip.go

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,14 @@ func ClientIPFromHeader(trustedHeader string) func(http.Handler) http.Handler {
6262
// set (fail-closed) — we can't safely trust anything left of garbage.
6363
//
6464
// Use this when you sit behind one or more reverse proxies whose IP ranges
65-
// you can enumerate as CIDRs:
65+
// you can enumerate as CIDRs. Most CDNs publish their IPs:
66+
//
67+
// Cloudflare: https://www.cloudflare.com/ips/
68+
// AWS: https://ip-ranges.amazonaws.com/ip-ranges.json
69+
// Fastly: https://api.fastly.com/public-ip-list
70+
// Google Cloud: https://www.gstatic.com/ipranges/cloud.json
71+
//
72+
// Example (CloudFront):
6673
//
6774
// r.Use(middleware.ClientIPFromXFF(
6875
// "13.32.0.0/15", // CloudFront IPv4
@@ -109,30 +116,34 @@ func ClientIPFromXFF(trustedIPPrefixes ...string) func(http.Handler) http.Handle
109116
}
110117
}
111118

112-
// ClientIPFromXFFTrustedProxies stores the client IP read from the
113-
// X-Forwarded-For header, given the exact number of trusted reverse proxies
114-
// between this server and the public internet. It returns the IP at position
115-
// len(xff) - numTrustedProxies in the merged X-Forwarded-For list — the IP
116-
// added by the outermost of your trusted proxies, the only IP in the chain
117-
// that none of your proxies have allowed an attacker to forge. Read it with
118-
// [GetClientIP].
119+
// ClientIPFromXFFTrustedProxies stores the client IP read from
120+
// X-Forwarded-For, given the exact number of trusted reverse proxies
121+
// between this server and the public internet. Read it with [GetClientIP].
122+
//
123+
// PREFER [ClientIPFromXFF] with explicit CIDRs whenever you can — it
124+
// cannot off-by-one and is robust to architecture changes. Most CDNs
125+
// publish their IP ranges (Cloudflare, AWS, Fastly, Google Cloud). Use
126+
// this counting variant only when proxy IPs are dynamic and unpublishable.
127+
//
128+
// numTrustedProxies = total proxy hops between the client and this server.
129+
// Count every hop in the request path:
119130
//
120-
// Use this when:
121-
// - You know exactly how many proxies you sit behind, AND
122-
// - Their IP addresses are dynamic (autoscaling proxy pools, ephemeral
123-
// containers, dynamic CDN edges) so listing CIDRs with [ClientIPFromXFF]
124-
// is impractical.
131+
// Single proxy (one LB / nginx / Heroku / Fly.io / Render) ....... 1
132+
// Two proxies (Cloudflare → ALB, CloudFront → ALB) .............. 2
133+
// Three proxies (CDN → API gateway → LB) ......................... 3
125134
//
126-
// WARNING: This variant is brittle to network architecture changes. If you
127-
// add or remove a proxy level, numTrustedProxies silently becomes wrong and
128-
// you may start trusting an attacker-supplied IP. Prefer [ClientIPFromXFF]
129-
// with explicit trusted CIDRs whenever you can.
135+
// VERIFY BEFORE GOING LIVE: send a request from a known IP and confirm
136+
// [GetClientIP] returns that IP. If it returns a proxy IP, your count is
137+
// too LOW — a client can spoof their IP, fix immediately. If it returns
138+
// "", your count is too HIGH — no leak, but no client IP either.
130139
//
131-
// If the XFF chain has fewer than numTrustedProxies entries (header missing
132-
// or architecture changed), no client IP is set and [GetClientIP] returns "".
140+
// This middleware reads ONLY X-Forwarded-For; it does not inspect
141+
// r.RemoteAddr. Guarantee at the network layer (security group / firewall)
142+
// that only your proxies can reach this server.
133143
//
134-
// Like [ClientIPFromXFF], v4-mapped IPv6 folds to plain v4 and IPv6 zones
135-
// are stripped before storage.
144+
// If the XFF chain has fewer than numTrustedProxies entries, no client IP
145+
// is set (fail-closed). Like [ClientIPFromXFF], v4-mapped IPv6 folds to v4
146+
// and IPv6 zones are stripped before storage.
136147
//
137148
// Panics at startup if numTrustedProxies < 1.
138149
func ClientIPFromXFFTrustedProxies(numTrustedProxies int) func(http.Handler) http.Handler {

middleware/client_ip_example_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,32 @@ func Example_clientIP() {
7777
fmt.Print(w.Body.String())
7878
// Output: 198.51.100.42
7979
}
80+
81+
// Example_clientIPFromXFFTrustedProxies shows that with two proxies the
82+
// client sits at XFF[len-2] and any client-prepended entries to the left of
83+
// that position are ignored.
84+
func Example_clientIPFromXFFTrustedProxies() {
85+
r := chi.NewRouter()
86+
r.Use(middleware.ClientIPFromXFFTrustedProxies(2)) // P1 (inner) + P2 (outer).
87+
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
88+
fmt.Fprintln(w, middleware.GetClientIP(r.Context()))
89+
})
90+
91+
// Each chain ends in "<client>, <P1>"; P2's address is in RemoteAddr,
92+
// not in X-Forwarded-For. Leading entries are client-controlled.
93+
for _, xff := range []string{
94+
"109.81.118.93, 3.172.119.73",
95+
"8.8.8.8, 109.81.118.93, 3.172.119.73",
96+
"8.8.8.8, 8.8.8.8, 8.8.8.8, 109.81.118.93, 3.172.119.73",
97+
} {
98+
req := httptest.NewRequest("GET", "/", nil)
99+
req.Header.Set("X-Forwarded-For", xff)
100+
w := httptest.NewRecorder()
101+
r.ServeHTTP(w, req)
102+
fmt.Print(w.Body.String())
103+
}
104+
// Output:
105+
// 109.81.118.93
106+
// 109.81.118.93
107+
// 109.81.118.93
108+
}

middleware/client_ip_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,10 @@ func TestClientIPFromXFFTrustedProxies(t *testing.T) {
196196

197197
// XFF shorter than N: no IP set. This is intentionally fail-closed so a
198198
// proxy-count mismatch doesn't silently fall through to attacker-controlled
199-
// values.
199+
// values. The index len(xff)-N is negative/out-of-range here; the walk
200+
// must never panic or wrap around to an entry the client controls.
200201
{"shorter_than_n", 3, []string{"1.1.1.1, 2.2.2.2"}, ""},
202+
{"single_entry_n_two", 2, []string{"2.2.2.2"}, ""},
201203
{"missing_header", 1, nil, ""},
202204

203205
// Spoofing: prepended attacker values are to the LEFT of the chosen

0 commit comments

Comments
 (0)