From 2dc3c023731c6f8e21f658a02e4a99eb22b889de Mon Sep 17 00:00:00 2001 From: BootstrapperSBL <131545713+BootstrapperSBL@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:13:37 +0800 Subject: [PATCH] fix(engine): accept bracketed IPv6 and port-suffixed entries in X-Forwarded-For MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validateHeader` called `net.ParseIP` directly on each comma-split item, so anything with brackets or a `:port` suffix got rejected silently and `ClientIP()` fell through to `RemoteAddr` — which means a client coming in through IIS/ARR or certain cloud LBs would show up as the reverse proxy instead of the real caller. The four forms called out in #4572 are all normal real-world outputs: - "192.168.8.39" - "240e:318:2f4a:de56::240" - "[240e:318:2f4a:de56::240]" - "192.168.8.39:38792" - "[240e:318:2f4a:de56::240]:38792" Extract a small `parseForwardedForItem` helper that tries `net.SplitHostPort` first (handles the two `:port` variants and strips brackets in the process) and falls back to bracket-stripping + `net.ParseIP` for bare `[ipv6]`. The returned `clientIP` is now always the bare IP regardless of which proxy produced the header, which keeps the shape of `ClientIP()` stable. Table tests cover all four reporter-listed forms, plus a chain with a port on the last entry and a couple of garbage inputs. Closes #4572 --- gin.go | 33 +++++++++++++++++++++++++++++++-- gin_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/gin.go b/gin.go index 2e033bf3..93c040ae 100644 --- a/gin.go +++ b/gin.go @@ -485,8 +485,8 @@ func (engine *Engine) validateHeader(header string) (clientIP string, valid bool } items := strings.Split(header, ",") for i := len(items) - 1; i >= 0; i-- { - ipStr := strings.TrimSpace(items[i]) - ip := net.ParseIP(ipStr) + item := strings.TrimSpace(items[i]) + ipStr, ip := parseForwardedForItem(item) if ip == nil { break } @@ -500,6 +500,35 @@ func (engine *Engine) validateHeader(header string) (clientIP string, valid bool return "", false } +// parseForwardedForItem normalizes a single X-Forwarded-For entry and parses it. +// It accepts the four common forms emitted by reverse proxies: +// +// - "1.2.3.4" +// - "2001:db8::1" +// - "[2001:db8::1]" (IIS/ARR style) +// - "1.2.3.4:12345" (with port, some LBs) +// - "[2001:db8::1]:12345" (IIS/ARR + port) +// +// The returned string is the IP without brackets or port, so callers see a +// consistent form regardless of which proxy produced the header. +func parseForwardedForItem(item string) (string, net.IP) { + // Try host:port form first (handles "ip:port" and "[ipv6]:port"). + if host, _, err := net.SplitHostPort(item); err == nil { + if ip := net.ParseIP(host); ip != nil { + return host, ip + } + } + // Strip optional surrounding brackets for bare "[ipv6]" with no port. + unbracketed := item + if strings.HasPrefix(unbracketed, "[") && strings.HasSuffix(unbracketed, "]") { + unbracketed = unbracketed[1 : len(unbracketed)-1] + } + if ip := net.ParseIP(unbracketed); ip != nil { + return unbracketed, ip + } + return "", nil +} + // updateRouteTree do update to the route tree recursively func updateRouteTree(n *node) { n.path = strings.ReplaceAll(n.path, escapedColon, colon) diff --git a/gin_test.go b/gin_test.go index a9cf1755..41f671e5 100644 --- a/gin_test.go +++ b/gin_test.go @@ -1156,3 +1156,35 @@ func TestUpdateRouteTreesCalledOnce(t *testing.T) { assert.Equal(t, "ok", w.Body.String()) } } + +func TestValidateHeaderForwardedForForms(t *testing.T) { + engine := New() + // Disable trusted proxies so the rightmost parseable entry is returned. + require.NoError(t, engine.SetTrustedProxies(nil)) + + tests := []struct { + name string + header string + wantIP string + wantOK bool + }{ + {"plain IPv4", "192.168.8.39", "192.168.8.39", true}, + {"plain IPv6", "240e:318:2f4a:de56::240", "240e:318:2f4a:de56::240", true}, + {"bracketed IPv6 (IIS/ARR)", "[240e:318:2f4a:de56::240]", "240e:318:2f4a:de56::240", true}, + {"IPv4 with port", "192.168.8.39:38792", "192.168.8.39", true}, + {"bracketed IPv6 with port", "[240e:318:2f4a:de56::240]:38792", "240e:318:2f4a:de56::240", true}, + {"IPv6 loopback bracketed", "[::1]", "::1", true}, + {"chain with port on last entry", "1.2.3.4, 5.6.7.8:9000", "5.6.7.8", true}, + {"empty", "", "", false}, + {"garbage", "not-an-ip", "", false}, + {"bracketed garbage", "[not-an-ip]", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotIP, gotOK := engine.validateHeader(tt.header) + assert.Equal(t, tt.wantOK, gotOK) + assert.Equal(t, tt.wantIP, gotIP) + }) + } +}