Skip to content

feat: middleware.ClientIP, a replacement for middleware.RealIP#967

Open
VojtechVitek wants to merge 4 commits into
masterfrom
clientip-middleware-intended-to-replace
Open

feat: middleware.ClientIP, a replacement for middleware.RealIP#967
VojtechVitek wants to merge 4 commits into
masterfrom
clientip-middleware-intended-to-replace

Conversation

@VojtechVitek
Copy link
Copy Markdown
Contributor

@VojtechVitek VojtechVitek commented Dec 15, 2024

A proposed solution for the RealIP middleware issues outlined at:
#708
https://adam-p.ca/blog/2022/03/x-forwarded-for/
#711
#453
#908

Fixes GHSA-9g5q-2w5x-hmxf, GHSA-rjr7-jggh-pgcp and GHSA-3fxj-6jh8-hvhx.

Summary of differences:

  • Unlike RealIP, ClientIP middleware doesn't change the value of req.RemoteAddr
  • Instead, it parses and saves the client IP to a context and provides .GetClientIP() getter
  • Makes users choose what to trust explicitly, e.g. a custom header / XFF header / req.RemoteAddr
  • XFF algorithm now selects the rightmost trusted IP address from all X-Forwarded-For headers
  • Additionally, XFF accepts a list of trusted IP prefixes, so users can opt-in to trust IP range
    belonging to their infrastructure, such as proxies or Load Balances, depending on their setup
  • Should prevent client IP spoofing, which could affect rate-limiting in https://github.com/go-chi/httprate

Kudos to Adam Pritchard, Jonathan Yu, Yuya Okumura, Liam Stanley, and everyone else involved in the detailed reports and discussion.

I'm seeking early feedback and reviews on the proposed API.

Big thanks to everyone involved:
@jawnsy @adam-p @convto @Dirbaio @rezmoss @c2h5oh @Saku0512 @mfridman @lrstanley @n33pm

P.S.: This PR uses the "net/netip" package and assumes that we'll accept proposal to drop support for Go 1.17 and older. Please have a look!


Example usage:

// Trust Cloudflare header:
r.Use(middleware.ClientIPFromHeader("CF-Connecting-IP"))
r.Use(middleware.ClientIPFromHeader("True-Client-IP")) // alternative in Cloudflare Enterprise
// Trust Azure header:
r.Use(middleware.ClientIPFromHeader("X-Azure-ClientIP"))
// Trust header from Nginx with ngx_http_realip_module:
r.Use(middleware.ClientIPFromHeader("X-Real-IP"))
// Trust header from Apache with mod_remoteip:
r.Use(middleware.ClientIPFromHeader("X-Client-IP"))
// Trust X-Forwarded-For header set by reverse-proxy in a private network:
r.Use(middleware.ClientIPFromXFFHeader("203.0.113.0/24"))
// Trust X-Forwarded-For header set by AWS CloudFront:
r.Use(middleware.ClientIPFromXFFHeader(
    "13.32.0.0/15",   // CloudFront IPv4
    "52.46.0.0/18",   // CloudFront IPv4
    "2600:9000::/28", // CloudFront IPv6
))
// Go server serving the Internet traffic directly:
r.Use(middleware.ClientIPFromRemoteAddr)

Get the client IP address value in HTTP handler:

clientIP := middleware.GetClientIP(r.Context())
// log the clientIP .. or use it for rate-limiting

Copy link
Copy Markdown

@convto convto left a comment

Choose a reason for hiding this comment

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

I’ve checked the implementation, and I completely agree with the approach.
It addresses the concerns beautifully, and I’m very grateful for the effort put into this!

@VojtechVitek VojtechVitek force-pushed the clientip-middleware-intended-to-replace branch from c2354ea to 4b70ef1 Compare January 20, 2025 20:46
@VojtechVitek
Copy link
Copy Markdown
Contributor Author

VojtechVitek commented Jan 20, 2025

P.S.: This PR uses the "net/netip" package and assumes that we'll accept #963.

Rebased against master. The CI tests are now passing since we dropped support for Go 1.19 and older and thus can we use "net/netip" pkg.

@jawnsy @adam-p @convto @Dirbaio @pkieltyka @mfridman @lrstanley @n33pm @c2h5oh

I'd appreciate any more reviews 👀 🙏 .

Comment thread middleware/client_ip.go Outdated
Comment thread middleware/client_ip.go Outdated
Comment thread middleware/client_ip.go Outdated
Comment thread middleware/client_ip.go Outdated
Comment thread middleware/client_ip.go Outdated
Comment thread middleware/client_ip.go Outdated
Comment thread middleware/client_ip.go Outdated
Comment thread middleware/client_ip_test.go
@rezmoss
Copy link
Copy Markdown

rezmoss commented Feb 1, 2026

test cases ip spoofing (feel free to edit/delete)

// TestClientIPFromXFFHeaderWithTrustedPrefixes tests the trusted CIDR prefix
func TestClientIPFromXFFHeaderWithTrustedPrefixes(t *testing.T) {
	tt := []struct {
		name            string
		xff             []string
		trustedPrefixes []string
		expected        string
	}{
		// single trusted proxy, proxy ip should be skipped
		{
			name:            "single_trusted_proxy",
			xff:             []string{"8.8.8.8, 203.0.113.50"},
			trustedPrefixes: []string{"203.0.113.0/24"},
			expected:        "8.8.8.8",
		},
		// multiple trusted proxies chain
		{
			name:            "multiple_trusted_proxies",
			xff:             []string{"1.1.1.1, 203.0.113.10, 198.51.100.5"},
			trustedPrefixes: []string{"203.0.113.0/24", "198.51.100.0/24"},
			expected:        "1.1.1.1",
		},
		// all ipss in trusted range,should return empty (no untrusted client)
		{
			name:            "all_ips_trusted",
			xff:             []string{"203.0.113.10, 203.0.113.20"},
			trustedPrefixes: []string{"203.0.113.0/24"},
			expected:        "",
		},
		// ipv6 trusted range e.g. cloudflare
		{
			name:            "ipv6_trusted_range",
			xff:             []string{"2001:db8::1, 2606:4700::1"},
			trustedPrefixes: []string{"2606:4700::/32"},
			expected:        "2001:db8::1",
		},
		// mixed ip and ipv6 trusted ranges
		{
			name:            "mixed_ipv4_ipv6_trusted",
			xff:             []string{"8.8.8.8, 2606:4700::1, 203.0.113.5"},
			trustedPrefixes: []string{"2606:4700::/32", "203.0.113.0/24"},
			expected:        "8.8.8.8",
		},
		// trusted prefix with longer chain
		{
			name:            "long_proxy_chain",
			xff:             []string{"1.2.3.4, 5.6.7.8, 203.0.113.1, 198.51.100.1"},
			trustedPrefixes: []string{"203.0.113.0/24", "198.51.100.0/24"},
			expected:        "5.6.7.8",
		},
	}

	for _, tc := range tt {
		t.Run(tc.name, func(t *testing.T) {
			req, _ := http.NewRequest("GET", "/", nil)
			for _, v := range tc.xff {
				req.Header.Add("X-Forwarded-For", v)
			}
			w := httptest.NewRecorder()

			r := chi.NewRouter()
			r.Use(ClientIPFromXFFHeader(tc.trustedPrefixes...))

			var clientIP string
			r.Get("/", func(w http.ResponseWriter, r *http.Request) {
				clientIP = GetClientIP(r.Context())
				w.Write([]byte("OK"))
			})
			r.ServeHTTP(w, req)

			if clientIP != tc.expected {
				t.Errorf("expected %q, got %q", tc.expected, clientIP)
			}
		})
	}
}

// TestXFFSpoofingPrevention tests ip spoofing attack scenarios in X-Forwarded-For context
func TestXFFSpoofingPrevention(t *testing.T) {
	tt := []struct {
		name            string
		xff             []string
		trustedPrefixes []string
		expected        string
		description     string
	}{
		{
			name:        "prepend_spoofed_loopback",
			xff:         []string{"127.0.0.1, 8.8.8.8"},
			expected:    "8.8.8.8",
			description: "Spoofed loopback ignored, real IP returned",
		},
		{
			name:        "prepend_spoofed_private",
			xff:         []string{"10.0.0.1, 8.8.8.8"},
			expected:    "8.8.8.8",
			description: "Spoofed private IP ignored, real IP returned",
		},
		// attacker prepends spoofed ip w trusted proxy in chain
		{
			name:            "spoof_with_trusted_proxy",
			xff:             []string{"127.0.0.1, 203.0.113.50, 198.51.100.1"},
			trustedPrefixes: []string{"198.51.100.0/24"},
			expected:        "203.0.113.50",
			description:     "Proxy skipped, spoofed loopback ignored, client IP returned",
		},
		// multiple xff headers,attacker injects first header
		{
			name:            "multi_header_injection_with_proxy",
			xff:             []string{"127.0.0.1", "8.8.8.8, 198.51.100.1"},
			trustedPrefixes: []string{"198.51.100.0/24"},
			expected:        "8.8.8.8",
			description:     "Attacker's header ignored, proxy chain processed correctly",
		},
		{
			name:        "multi_header_injection_loopback",
			xff:         []string{"127.0.0.1", "8.8.8.8"},
			expected:    "8.8.8.8",
			description: "Attacker injects loopback in separate header",
		},
		// rate limit bypass prevention,spoofed ipsshould be ignored
		{
			name:            "rate_limit_bypass_attempt",
			xff:             []string{"attacker.spoofed.ip, 8.8.8.8, 198.51.100.1"},
			trustedPrefixes: []string{"198.51.100.0/24"},
			expected:        "8.8.8.8",
			description:     "Invalid spoofed IP ignored, consistent client IP for rate limiting",
		},
	}

	for _, tc := range tt {
		t.Run(tc.name, func(t *testing.T) {
			req, _ := http.NewRequest("GET", "/", nil)
			for _, v := range tc.xff {
				req.Header.Add("X-Forwarded-For", v)
			}
			w := httptest.NewRecorder()

			r := chi.NewRouter()
			r.Use(ClientIPFromXFFHeader(tc.trustedPrefixes...))

			var clientIP string
			r.Get("/", func(w http.ResponseWriter, r *http.Request) {
				clientIP = GetClientIP(r.Context())
				w.Write([]byte("OK"))
			})
			r.ServeHTTP(w, req)

			if clientIP != tc.expected {
				t.Errorf("%s: expected %q, got %q", tc.description, tc.expected, clientIP)
			}
		})
	}
}

// TestClientIPMiddlewareChaining verifies that the first middleware to set
func TestClientIPMiddlewareChaining(t *testing.T) {
	t.Run("header_before_xff", func(t *testing.T) {
		req, _ := http.NewRequest("GET", "/", nil)
		req.Header.Set("CF-Connecting-IP", "1.1.1.1")
		req.Header.Set("X-Forwarded-For", "2.2.2.2")
		w := httptest.NewRecorder()

		r := chi.NewRouter()
		r.Use(ClientIPFromHeader("CF-Connecting-IP"))
		r.Use(ClientIPFromXFFHeader())

		var clientIP string
		r.Get("/", func(w http.ResponseWriter, r *http.Request) {
			clientIP = GetClientIP(r.Context())
			w.Write([]byte("OK"))
		})
		r.ServeHTTP(w, req)

		expected := "1.1.1.1"
		if clientIP != expected {
			t.Errorf("expected %q (from header), got %q", expected, clientIP)
		}
	})

	t.Run("xff_before_remoteaddr", func(t *testing.T) {
		req, _ := http.NewRequest("GET", "/", nil)
		req.Header.Set("X-Forwarded-For", "8.8.8.8")
		req.RemoteAddr = "192.0.2.1:1234"
		w := httptest.NewRecorder()

		r := chi.NewRouter()
		r.Use(ClientIPFromXFFHeader())
		r.Use(ClientIPFromRemoteAddr)

		var clientIP string
		r.Get("/", func(w http.ResponseWriter, r *http.Request) {
			clientIP = GetClientIP(r.Context())
			w.Write([]byte("OK"))
		})
		r.ServeHTTP(w, req)

		expected := "8.8.8.8"
		if clientIP != expected {
			t.Errorf("expected %q (from XFF), got %q", expected, clientIP)
		}
	})

	t.Run("empty_header_falls_through", func(t *testing.T) {
		req, _ := http.NewRequest("GET", "/", nil)
		req.Header.Set("X-Forwarded-For", "8.8.8.8")
		w := httptest.NewRecorder()

		r := chi.NewRouter()
		r.Use(ClientIPFromHeader("CF-Connecting-IP"))
		r.Use(ClientIPFromXFFHeader())

		var clientIP string
		r.Get("/", func(w http.ResponseWriter, r *http.Request) {
			clientIP = GetClientIP(r.Context())
			w.Write([]byte("OK"))
		})
		r.ServeHTTP(w, req)

		expected := "8.8.8.8"
		if clientIP != expected {
			t.Errorf("expected %q (from XFF fallback), got %q", expected, clientIP)
		}
	})

	t.Run("private_header_falls_through", func(t *testing.T) {
		req, _ := http.NewRequest("GET", "/", nil)
		req.Header.Set("X-Real-IP", "192.168.1.1") // private ignoredd
		req.Header.Set("X-Forwarded-For", "8.8.8.8")
		w := httptest.NewRecorder()

		r := chi.NewRouter()
		r.Use(ClientIPFromHeader("X-Real-IP"))
		r.Use(ClientIPFromXFFHeader())

		var clientIP string
		r.Get("/", func(w http.ResponseWriter, r *http.Request) {
			clientIP = GetClientIP(r.Context())
			w.Write([]byte("OK"))
		})
		r.ServeHTTP(w, req)

		expected := "8.8.8.8"
		if clientIP != expected {
			t.Errorf("expected %q (XFF after private rejected), got %q", expected, clientIP)
		}
	})
}

// TestPrivateIPWithTrustedPrefixes Should IsPrivate()/IsLoopback() checks be dropped when trusted prefixes are configured based  adam-p blog
func TestPrivateIPWithTrustedPrefixes(t *testing.T) {
	tt := []struct {
		name            string
		xff             string
		trustedPrefixes []string
		currentResult   string
		proposedResult  string
		description     string
	}{
		// k8s pod to pod: both ip are private
		{
			name:            "k8s_pod_to_pod",
			xff:             "10.244.1.50, 10.244.0.10",
			trustedPrefixes: []string{"10.244.0.0/24"},
			currentResult:   "",            // both private both skipped
			proposedResult:  "10.244.1.50", // only trusted prefix filters
			description:     "Pod client through service mesh sidecar",
		},
		// nginx ingress with private proxy ip
		{
			name:            "nginx_private_proxy",
			xff:             "203.0.113.50, 192.168.1.10",
			trustedPrefixes: []string{"192.168.1.0/24"},
			currentResult:   "203.0.113.50", // woks public client
			proposedResult:  "203.0.113.50", // dame
			description:     "Public client through private nginx",
		},
		// aws internal load balancer
		{
			name:            "aws_internal_lb",
			xff:             "8.8.8.8, 10.0.0.50",
			trustedPrefixes: []string{"10.0.0.0/16"},
			currentResult:   "8.8.8.8", // works LB skipped via trusted prefix
			proposedResult:  "8.8.8.8", // same
			description:     "Public client through internal LB",
		},
	}

	for _, tc := range tt {
		t.Run(tc.name, func(t *testing.T) {
			req, _ := http.NewRequest("GET", "/", nil)
			req.Header.Set("X-Forwarded-For", tc.xff)
			w := httptest.NewRecorder()

			r := chi.NewRouter()
			r.Use(ClientIPFromXFFHeader(tc.trustedPrefixes...))

			var clientIP string
			r.Get("/", func(w http.ResponseWriter, r *http.Request) {
				clientIP = GetClientIP(r.Context())
				w.Write([]byte("OK"))
			})
			r.ServeHTTP(w, req)

			t.Logf("Scenario: %s", tc.description)
			t.Logf("XFF: %s, Trusted: %v", tc.xff, tc.trustedPrefixes)
			t.Logf("Result: %q (current expected: %q, proposed: %q)",
				clientIP, tc.currentResult, tc.proposedResult)

			if clientIP != tc.currentResult && clientIP != tc.proposedResult {
				t.Errorf("unexpected: got %q", clientIP)
			}
			if clientIP == tc.proposedResult && tc.proposedResult != tc.currentResult {
				t.Logf("now matches")
			}
		})
	}
}

Saku0512 added a commit to Saku0512/chi that referenced this pull request Apr 18, 2026
Covers single/multiple trusted proxy chains, spoofing attempts,
prefix boundary values, and no-prefix baseline as requested in PR go-chi#967.
@VojtechVitek VojtechVitek changed the title ClientIP middleware proposal, intended to replace RealIP feat: middleware.ClientIP, a replacement for middleware.RealIP May 17, 2026
@VojtechVitek
Copy link
Copy Markdown
Contributor Author

Thanks everyone for the thorough reviews and feedback on this PR. I've been figuring out how to work through it all — the comments, open advisories, and decisions needed. With a bit of help from Opus 4.7, I've worked through everything and landed on a plan. Give it a read and let me know if anything stands out.
I'll execute on the plan and have a final commit ready to merge.


PR #967 — ClientIP middleware: Plan to finish

A plan to address the open review feedback on
PR #967, close out the three
published security advisories against middleware.RealIP, and land a
small, safe, easy-to-use ClientIP* middleware family.


Why this matters — three open advisories

Chi currently has three published security advisories, all
targeting the same root cause: middleware.RealIP blindly trusts
client-supplied headers.

Advisory Reporter Severity Status
GHSA-9g5q-2w5x-hmxf — IP spoofing via XFF in RemoteAddr resolution convto Moderate Unpatched
GHSA-rjr7-jggh-pgcp — RealIP allows IP spoofing via unvalidated XFF rezmoss Moderate Unpatched
GHSA-3fxj-6jh8-hvhx — IP spoofing in middleware.RealIP Saku0512 Critical (9.3) Unpatched

All three reporters propose the same fix shape: rightmost-untrusted
traversal of X-Forwarded-For with an explicit trusted-CIDR list.
That is exactly what ClientIPFromXFF does (renamed from
ClientIPFromXFFHeader in this PR).

This PR is the patch for all three advisories and the
deprecation of middleware.RealIP is part of that patch.


What the source-of-truth references say

adam-p's "The perils of the 'real' client IP"
is the definitive write-up. The points that drive our API:

  1. Rightmost-ish, never leftmost. Leftmost is trivially spoofable.
  2. There's no sane default — so there should be no default. The
    blog calls out chi by name on this. ("Conclusion middleware.Logger: Stop sending any headers, defer printEnd() #4")
  3. Defaults must not be insecure. No auto-fall-back through a
    list of headers an attacker could supply. ("Conclusion Middleware function gets recreated every function call #5")
  4. Any header you trust must be set/overwritten by your own
    infrastructure for every request
    — otherwise it's
    attacker-controlled.
  5. Multiple X-Forwarded-For headers must be merged, not just
    Header.Get()-ed.
  6. Behind multiple proxies, you need either trusted-proxy
    IPs/prefixes OR a trusted-proxy count
    (rightmost-ish algorithm).
  7. RemoteAddr can be ip:port — split correctly.
  8. Re-fronting attack — even a "trusted" CDN IP can be
    attacker-controlled if a third-party distribution points at your
    origin. Mitigation is out-of-band (shared secret / client cert at
    the origin). Out of scope to fix in middleware; doc warning only.
  9. Forwarded (RFC 7239) has the same problems and almost
    nobody uses it. Skip.

Summary of reviewer feedback on PR #967

c2h5oh (CHANGES_REQUESTED)

  1. client_ip.go L112 — when trustedIPPrefixes is set, the
    IsLoopback / IsPrivate / IsUnspecified rejection inside the XFF
    loop is wrong: "no loopback, private or unspecified addresses
    should exist past/in between trusted prefixes unless headers have
    been tampered with."
  2. (Jun 2025) Real-world hit: nginx-ingress in k8s legitimately
    produces private IPs in the chain — the current code skips them
    and breaks lookups.
  3. New API idea: a trusted-proxy-count knob (Traefik-style) for
    the case where you control exactly N proxies but not all possible
    proxies on a corporate network.

adam-p (CHANGES_REQUESTED)

  1. L32 godocX-Azure-ClientIP, True-Client-IP,
    Fastly-Client-IP are NOT trustworthy headers per his blog and
    shouldn't be listed as such.
  2. L46 and L112 — drop all the IsLoopback / IsPrivate / IsUnspecified second-guessing in both ClientIPFromHeader and
    ClientIPFromXFFHeader. The user has explicitly said what to
    trust; we shouldn't override that.
  3. L112 — a ParseAddr failure mid-chain indicates a
    configuration error; it shouldn't be silently skipped. Suggests
    an error / flag / callback (no strong preference).
  4. L137 godoc (ClientIPFromRemoteAddr) — comment claims it
    "ignores invalid or private IPs", but it doesn't.
  5. L181 GetClientIPip.IsValid() check may be redundant if
    we drop second-guessing.
  6. L73 tests — add tests that exercise the trusted-range
    functionality.
  7. (Jun 2025) Reaffirms: even nginx-ingress private IPs should be
    configured by the user via trustedIPPrefixes, not auto-filtered.

rezmoss (proposed tests on PR #967)

Four ready-to-paste test functions:

  • TestClientIPFromXFFHeaderWithTrustedPrefixes — CIDR behavior incl.
    IPv6, mixed v4/v6, long chains.
  • TestXFFSpoofingPrevention — rightmost-untrusted defeats spoofing.
  • TestClientIPMiddlewareChaining — header → xff → remoteaddr
    fallthrough.
  • TestPrivateIPWithTrustedPrefixes — k8s, nginx, AWS internal LB.

Saku0512 (follow-up PR #1087 on top of #967)

Adds TestClientIPFromXFFHeaderWithTrustedPrefixes with boundary
tests: .0 and .255 of a /24, IP just outside, all-trusted →
empty, no-prefixes baseline. Cleanly aligned with our API. We
pull these in (or merge #1087 once we've landed the core changes).

Convergence

Both blocking reviewers and all three advisory reporters
(rezmoss explicitly, convto and Saku0512 by algorithm) converge on
one thing: stop second-guessing the user. Drop the
IsPrivate / IsLoopback / IsUnspecified rejection and let the
explicit trust list be authoritative. This also unblocks the
legitimate k8s / nginx-ingress / internal-LB cases.


Is the API right?

Final surface for this PR:

// Trust a single-IP header set by your infrastructure.
func ClientIPFromHeader(trustedHeader string) func(http.Handler) http.Handler

// Trust XFF, walking right-to-left and skipping the listed trusted CIDRs.
// Zero args ⇒ rightmost parseable IP (safe only behind one trusted hop).
func ClientIPFromXFF(trustedIPPrefixes ...string) func(http.Handler) http.Handler

// Trust XFF, knowing the exact number of trusted proxies in front of you.
// Returns xff[len-numTrustedProxies], or empty if XFF has fewer entries.
// Panics if numTrustedProxies < 1.
func ClientIPFromXFFTrustedProxies(numTrustedProxies int) func(http.Handler) http.Handler

// Trust the TCP RemoteAddr (only safe when directly connected to the internet).
func ClientIPFromRemoteAddr(h http.Handler) http.Handler

// Accessors.
func GetClientIP(ctx context.Context) string         // empty if not set
func GetClientIPAddr(ctx context.Context) netip.Addr // zero value if not set

Scored against the blog + advisory checklist:

Requirement Status
Rightmost-untrusted algorithm ClientIPFromXFF
No default header fallback list ✅ separate middlewares; user composes
Merge multiple XFF headers Header.Values("X-Forwarded-For") joined
Trusted CIDRs / IPs ✅ variadic trustedIPPrefixes ...string
Trusted-proxy-COUNT alternative (Traefik-style) ClientIPFromXFFTrustedProxies(n)
Validate before parsing ✅ via netip.ParseAddr
Don't mutate RemoteAddr ✅ context-based
Doesn't second-guess what the user trusts 🟡 still rejects private/loopback — dropped in Phase 1
Typed accessor for netip.Addr GetClientIPAddr(ctx)
Strong security warnings in godoc 🟡 insufficient — expanded in Phase 1
Misleading "trusted" header examples 🟡 lists Akamai/Azure/Fastly — removed in Phase 1
Document re-fronting / dual-connection / network-change risks ❌ added in Phase 1

Why two XFF functions and not one?

ClientIPFromXFF(prefixes...) and
ClientIPFromXFFTrustedProxies(n) configure trust in fundamentally
different ways:

What you specify Best when
ClientIPFromXFF(prefixes...) IP ranges of your proxies You know the IPs (static fleet, on-prem, Cloudflare's published list)
ClientIPFromXFFTrustedProxies(n) Number of proxies You don't know the IPs but you know the hop count (autoscaling, ephemeral pools)

They overlap intentionally for the trivial case: (no prefixes) and
TrustedProxies(1) both return the rightmost parseable IP. That's
fine — the user picks based on what they actually know about their
infra.


Proposed plan

Phase 1 — Code changes to middleware/client_ip.go

  1. ClientIPFromHeader (adam-p L46, advisories)
    Drop the IsLoopback || IsUnspecified || IsPrivate rejection.
    Parse-fail still rejects. The user has chosen the header
    explicitly; if their infra writes a private IP there, that's the
    answer.

  2. Rename ClientIPFromXFFHeaderClientIPFromXFF
    Shorter, pairs nicely with ClientIPFromRemoteAddr. Drop the
    IsLoopback || IsPrivate || IsUnspecified rejection inside the
    loop (c2h5oh L112, adam-p L112, all three advisories). Walk
    right-to-left, skip values matching trustedIPPrefixes, return
    the first remaining IP. Pure rightmost-untrusted, no
    second-guessing.

    Keep silent-skip on ParseAddr failure mid-chain (chi has no
    logger here; buggy proxies do emit garbage; revisit in a
    follow-up if anyone asks).

  3. NEW: ClientIPFromXFFTrustedProxies (c2h5oh's ask)
    Traefik-style trusted-proxy count variant. Algorithm: merge all
    XFF headers into a single list, then return
    xff[len(xff) - numTrustedProxies] if (a) that index exists and
    (b) the value parses as an IP. Otherwise don't set a client IP
    (downstream middleware like ClientIPFromRemoteAddr can be
    chained as a fallback). Panic at construction if
    numTrustedProxies < 1.

  4. ClientIPFromRemoteAddr (adam-p L137)
    Fix the godoc — it currently claims "ignores invalid or private
    IPs" but doesn't filter private. Remove the false claim.

  5. Godoc cleanup for ClientIPFromHeader (adam-p L32)
    Drop X-Azure-ClientIP, True-Client-IP, Fastly-Client-IP
    from the examples (they're not reliably infra-overwritten per
    the blog). Keep X-Real-IP, X-Client-IP, CF-Connecting-IP.
    Full text in Phase 1.5.

  6. NEW: GetClientIPAddr, refactor GetClientIP (adam-p L181,
    user request)
    Add a typed accessor returning netip.Addr; GetClientIP
    becomes a thin wrapper around it. Keep the IsValid() check.
    Full text in Phase 1.5.

  7. NEW: package-level security guidance
    Add a doc block at the top of client_ip.go. Full text in
    Phase 1.5 ("Choosing a ClientIPFrom* middleware"). The block
    also expands into per-function godocs that cover:

    • HTTPS required (plaintext can be modified in transit).
    • Firewall must restrict ingress to your trusted edge.
    • Re-fronting attack (third-party CDN distribution → your origin).
    • Network-architecture changes require re-checking config.

Phase 1.5 — Godoc spec (exact text to drop into the code)

Package-level decision guide

Placed above the first ClientIPFrom* function in client_ip.go:

// # Choosing a ClientIPFrom* middleware
//
// There is no safe default. Apply exactly ONE ClientIPFrom* middleware
// per request, based on your network setup:
//
//   - [ClientIPFromRemoteAddr] — this server is directly on the public
//     internet, with no reverse proxy in front.
//
//   - [ClientIPFromHeader] — your reverse proxy sets a dedicated
//     single-IP header (X-Real-IP, CF-Connecting-IP, X-Client-IP)
//     and overwrites any client-supplied value for every request.
//
//   - [ClientIPFromXFF] — you sit behind one or more reverse proxies
//     whose IP ranges you can enumerate (your VPC CIDR, Cloudflare's
//     published IP list, etc.).
//
//   - [ClientIPFromXFFTrustedProxies] — you sit behind a known, fixed
//     number of reverse proxies whose IPs are dynamic (autoscaling
//     pools, ephemeral containers).
//
// Read the resulting IP with [GetClientIP] (string) or
// [GetClientIPAddr] (netip.Addr).

ClientIPFromHeader

// ClientIPFromHeader stores the client IP read from a single-IP HTTP
// header set by your reverse proxy. Read the IP with [GetClientIP].
//
// Use this when your reverse proxy sets one of these headers for every
// request and overwrites any client-supplied value:
//
//   - X-Real-IP        — Nginx with ngx_http_realip_module
//   - X-Client-IP      — Apache with mod_remoteip
//   - CF-Connecting-IP — Cloudflare
//
// DO NOT use this with headers your infrastructure does not overwrite
// (True-Client-IP, X-Azure-ClientIP, Fastly-Client-IP by default,
// etc.). Those can be supplied by the client and are trivially
// spoofable. See https://adam-p.ca/blog/2022/03/x-forwarded-for/ for
// details.
//
// Pick exactly one ClientIPFrom* middleware for your application; see
// the "Choosing a ClientIPFrom* middleware" section above.

ClientIPFromXFF

// ClientIPFromXFF stores the client IP read from the X-Forwarded-For
// header, walking the chain right-to-left and skipping any IP that
// falls within one of the given trusted CIDR prefixes. The first IP
// that is not trusted is the client. Read it with [GetClientIP].
//
// Use this when you sit behind one or more reverse proxies whose IP
// ranges you can enumerate as CIDRs:
//
//   r.Use(middleware.ClientIPFromXFF(
//       "13.32.0.0/15",   // CloudFront IPv4
//       "52.46.0.0/18",   // CloudFront IPv4
//       "2600:9000::/28", // CloudFront IPv6
//   ))
//
// Calling with no arguments returns the rightmost parseable XFF IP —
// safe only if you have exactly one trusted hop directly in front of
// this server (e.g., nginx on localhost).
//
// If you know the number of trusted proxies but not their IPs, use
// [ClientIPFromXFFTrustedProxies] instead.
//
// Panics at startup if any prefix is invalid. Pick exactly one
// ClientIPFrom* middleware for your application.

ClientIPFromXFFTrustedProxies

// ClientIPFromXFFTrustedProxies stores the client IP read from the
// X-Forwarded-For header, given the exact number of trusted reverse
// proxies between this server and the public internet. It returns the
// IP at position len(xff) - numTrustedProxies in the merged
// X-Forwarded-For list — the IP added by the outermost of your
// trusted proxies, the only IP in the chain that none of your proxies
// have allowed an attacker to forge. Read it with [GetClientIP].
//
// Use this when:
//   - You know exactly how many proxies you sit behind, AND
//   - Their IP addresses are dynamic (autoscaling proxy pools,
//     ephemeral containers, dynamic CDN edges) so listing CIDRs
//     with [ClientIPFromXFF] is impractical.
//
// WARNING: This variant is brittle to network architecture changes.
// If you add or remove a proxy level, numTrustedProxies silently
// becomes wrong and you may start trusting an attacker-supplied IP.
// Prefer [ClientIPFromXFF] with explicit trusted CIDRs whenever you
// can.
//
// If the XFF chain has fewer than numTrustedProxies entries (header
// missing or architecture changed), no client IP is set; chain
// [ClientIPFromRemoteAddr] after this middleware if you want a
// fallback.
//
// Panics at startup if numTrustedProxies < 1. Pick exactly one
// ClientIPFrom* middleware for your application.

ClientIPFromRemoteAddr

// ClientIPFromRemoteAddr stores the client IP read from the TCP
// RemoteAddr of the incoming request — the IP address of whoever
// opened the connection to this server. Read it with [GetClientIP].
//
// Use this when this server is directly connected to the public
// internet with NO reverse proxy in front of it. Behind a reverse
// proxy, RemoteAddr is the proxy's IP, not the client's — use
// [ClientIPFromHeader] or [ClientIPFromXFF] instead.
//
// Pick exactly one ClientIPFrom* middleware for your application.

GetClientIP and GetClientIPAddr

// GetClientIP returns the client IP as a string, as set by one of the
// ClientIPFrom* middlewares. Returns "" if no valid IP was set.
// Convenient for logging, rate-limit keys, etc.
func GetClientIP(ctx context.Context) string

// GetClientIPAddr returns the client IP as a netip.Addr, as set by one
// of the ClientIPFrom* middlewares. The returned Addr is the zero
// value if not set; use .IsValid() to check. Useful when you need
// typed work — prefix containment, Is4/Is6, etc. — without re-parsing.
func GetClientIPAddr(ctx context.Context) netip.Addr

Phase 2 — Update middleware/client_ip_test.go

  1. Fix existing tests for ClientIPFromHeader
    invalid_loopback, invalid_zeroes, invalid_private_* cases
    become passing cases (we no longer filter). Rename them and
    set out equal to in.

  2. Fix existing tests for ClientIPFromXFF (renamed from
    ClientIPFromXFFHeader)
    Most stay; adjust any that depended on filtering. Update test
    function names to match.

  3. Adopt new tests

  4. NEW: tests for ClientIPFromXFFTrustedProxies

    • N=1 returns rightmost (equivalence with zero-arg
      ClientIPFromXFF()).
    • N=2 skips 1 rightmost, returns 2nd from right.
    • N=3 skips 2 rightmost, returns 3rd from right.
    • len(xff) < N → empty (no IP set, downstream fallback works).
    • len(xff) == N → leftmost.
    • Spoofing attempt: attacker prepends fake IPs → still picks the
      correct slot, ignoring everything to its left.
    • Bad parse at target slot → empty (no IP set).
    • Construction with N < 1 panics.
  5. NEW: tests for GetClientIPAddr

    • Returns zero netip.Addr when not set (!IsValid()).
    • Round-trips against GetClientIP for set values.

Phase 3 — Deprecate middleware.RealIP

  1. middleware/realip.go — add a // Deprecated: godoc on
    RealIP:

    Deprecated: RealIP is vulnerable to IP spoofing — see
    GHSA-3fxj-6jh8-hvhx, GHSA-rjr7-jggh-pgcp,
    GHSA-9g5q-2w5x-hmxf. Use [ClientIPFromXFF],
    [ClientIPFromHeader] or [ClientIPFromRemoteAddr] instead, and
    read the value with [GetClientIP].

    Keep the function working for backward compatibility. Removal
    can be considered for a future major release.

  2. README — short section pointing migrators from RealIP to
    ClientIP*, with copy-paste recipes for the common deployment
    shapes (direct, behind nginx, behind Cloudflare, behind AWS
    ALB).

Phase 4 — Update PR description

  1. Remove the X-Azure-ClientIP example from the PR body.
  2. Add an example for the new ClientIPFromXFFTrustedProxies
    variant.
  3. Note that this PR is the patch for the three advisories listed
    above and that middleware.RealIP is being deprecated.

Phase 5 — Publish the advisories

  1. Once feat: middleware.ClientIP, a replacement for middleware.RealIP #967 is merged and a release cut, mark all three GHSAs as
    patched in that release. Update CVE info if assigned.

Open questions — resolved

  • Q1 (Traefik-style proxy count): ✅ INCLUDED in this PR as
    ClientIPFromXFFTrustedProxies(numTrustedProxies int). See
    Phase 1 step 3.

  • Q2 (zero-arg ClientIPFromXFF()): ✅ Kept valid → returns
    rightmost parseable IP. Documented as "safe only when you have
    exactly one trusted hop directly in front of you." Overlaps with
    TrustedProxies(1) by design; user picks based on whether they
    know IPs or proxy count.

  • Q3 (ParseAddr failure mid-chain): ✅ Defer to a follow-up.
    v1 silently skips. Future extension point if anyone asks:

    type XFFOpts struct {
        OnParseError func(r *http.Request, value string, err error)
    }
    func ClientIPFromXFFWithOptions(opts XFFOpts, trustedIPPrefixes ...string) ...
  • Q4 (typed accessor GetClientIPAddr): ✅ INCLUDED in this PR.
    Returns netip.Addr (not net.Addr — that's an endpoint interface
    including a port). Essentially free since we already store
    netip.Addr in the context. GetClientIP becomes a thin wrapper.


Out of scope (mentioned for completeness)

  • go-chi/httprate.LimitByRealIP — separate repo. It also
    needs to switch to the new model. Open a follow-up issue there
    referencing this PR.
  • RFC 7239 Forwarded header support — almost nobody uses it,
    same problems as XFF. Not worth the API surface.
  • Auto-verifying the immediate RemoteAddr against a trusted
    edge
    — adam-p mentions this as a defense for the
    "behind-proxy-and-internet" anti-pattern. Better solved at the
    firewall/security-group layer than in middleware. Doc warning.

Summary — the ClientIP API

Four middlewares, two accessors. There is no safe default — the user
picks exactly one middleware based on their network setup, and reads
the resulting IP with one of two accessors:

// One of the four. Pick based on your infrastructure.
func ClientIPFromHeader(trustedHeader string) func(http.Handler) http.Handler
func ClientIPFromXFF(trustedIPPrefixes ...string) func(http.Handler) http.Handler
func ClientIPFromXFFTrustedProxies(numTrustedProxies int) func(http.Handler) http.Handler
func ClientIPFromRemoteAddr(h http.Handler) http.Handler

// One of the two. Pick based on what you need.
func GetClientIP(ctx context.Context) string         // for logs, rate-limit keys
func GetClientIPAddr(ctx context.Context) netip.Addr // for typed work
Your setup Use
Directly on the public internet, no proxy ClientIPFromRemoteAddr
Behind nginx (X-Real-IP), Cloudflare (CF-Connecting-IP), Apache (X-Client-IP) ClientIPFromHeader(...)
Behind one or more proxies whose IP ranges you can list ClientIPFromXFF(cidr1, cidr2, …)
Behind a fixed number of proxies with dynamic IPs ClientIPFromXFFTrustedProxies(n)

The implementation is rightmost-untrusted, never second-guesses what
the user trusts, doesn't mutate r.RemoteAddr, and stores
netip.Addr in context. It is the patch for GHSA-3fxj-6jh8-hvhx,
GHSA-rjr7-jggh-pgcp, and GHSA-9g5q-2w5x-hmxf, and supersedes the
now-deprecated middleware.RealIP.

Rework the new ClientIP* middlewares per reviewer feedback (c2h5oh,
adam-p, rezmoss, Saku0512) and align with the documented attack
patterns in GHSA-3fxj-6jh8-hvhx, GHSA-rjr7-jggh-pgcp, GHSA-9g5q-2w5x-hmxf.

API:
- Rename ClientIPFromXFFHeader -> ClientIPFromXFF.
- Add ClientIPFromXFFTrustedProxies(n) for dynamic proxy pools where
  enumerating CIDRs isn't practical (autoscaling, ephemeral containers).
- Add GetClientIPAddr alongside GetClientIP; both back to a single
  netip.Addr stored in context. r.RemoteAddr is never mutated.
- Drop IsLoopback/IsPrivate filtering: the user's explicit trust
  configuration is authoritative (k8s nginx-ingress and similar
  legitimately surface those values).
- Merge multiple X-Forwarded-For header instances before walking
  (RFC 2616), defeating duplicate-header attacks.
- Deprecate middleware.RealIP with citations to all three advisories
  and guidance pointing at the new API.

Docs:
- Per-function godoc explains exactly when each variant applies.
- Example_clientIP is a single, consolidated decision guide rendered
  on pkg.go.dev.

Tests: 56 subtests including explicit PoC reproductions of each
advisory, /24 boundary cases (Saku0512), IPv6, multi-header merging,
spoofing prevention, and middleware chaining (rezmoss).

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
@VojtechVitek VojtechVitek force-pushed the clientip-middleware-intended-to-replace branch from 2268552 to 716b67a Compare May 17, 2026 13:30
@VojtechVitek
Copy link
Copy Markdown
Contributor Author

@c2h5oh @adam-p @Saku0512 please review :)

@VojtechVitek VojtechVitek requested review from adam-p and c2h5oh May 17, 2026 13:32
Copy link
Copy Markdown

@Saku0512 Saku0512 left a comment

Choose a reason for hiding this comment

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

Requesting changes for one documentation issue that can lead to incorrect client IP selection in real deployments. The implementation looks good, but the fallback ordering guidance for ClientIPFromXFFTrustedProxies appears reversed for chi's middleware execution order.

Comment thread middleware/client_ip.go Outdated
Saku0512 correctly flagged in #967 that the godoc told users to register
ClientIPFromRemoteAddr *after* ClientIPFromXFFTrustedProxies for fallback
behavior. With last-write-wins semantics (482e855) that recipe is actively
wrong: on the happy path RemoteAddr would silently overwrite the legitimate
XFF-derived client IP with the immediate proxy's address — reintroducing the
spoofing-prone behavior this API was written to prevent.

Rather than just reverse the order in the example, drop the chaining hint
entirely. The whole point of 482e855 was to push users toward picking exactly
one ClientIPFrom* middleware; documenting a chaining recipe in one specific
function works against that. The honest statement is "no client IP is set
and GetClientIP returns \"\"" — what the caller does about it is on them.

Also rename TestClientIPChaining -> TestClientIPLastWriteWins and reword its
subtests to describe the property being locked down rather than endorsing a
fallback recipe. Same coverage, no implicit recommendation.
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.

6 participants