From 55df62e4f61eb979e68a95ed2674e74d62a96fdf Mon Sep 17 00:00:00 2001 From: xingzihai <1315258019@qq.com> Date: Sun, 29 Mar 2026 19:43:39 +0000 Subject: [PATCH] fix(context): add tests and documentation for ClientIP with X-Forwarded-For Add comprehensive tests for ClientIP behavior with X-Forwarded-For header handling, including edge cases for trusted proxies. Issue: #4572 --- context_test.go | 25 +++++++++++++++++++++++++ gin.go | 20 ++++++++++++++++++++ gin_test.go | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/context_test.go b/context_test.go index ef60379d77..40772b7912 100644 --- a/context_test.go +++ b/context_test.go @@ -2107,6 +2107,31 @@ func TestContextClientIP(t *testing.T) { c.engine.TrustedPlatform = "" + // Test non-standard X-Forwarded-For header content (issue #4572) + // IPv6 with brackets only + resetContextForClientIPTests(c) + _ = c.engine.SetTrustedProxies([]string{"40.40.40.40"}) + c.Request.Header.Set("X-Forwarded-For", " [::1], 20.20.20.20, 30.30.30.30") + assert.Equal(t, "::1", c.ClientIP()) + + // IPv6 with brackets and port + resetContextForClientIPTests(c) + _ = c.engine.SetTrustedProxies([]string{"40.40.40.40"}) + c.Request.Header.Set("X-Forwarded-For", "[2001:db8::1]:8080, 30.30.30.30") + assert.Equal(t, "2001:db8::1", c.ClientIP()) + + // IPv4 with port + resetContextForClientIPTests(c) + _ = c.engine.SetTrustedProxies([]string{"40.40.40.40", "30.30.30.30"}) + c.Request.Header.Set("X-Forwarded-For", "20.20.20.20:8888, 30.30.30.30") + assert.Equal(t, "20.20.20.20", c.ClientIP()) + + // Mixed: IPv6 with brackets, IPv4 with port + resetContextForClientIPTests(c) + _ = c.engine.SetTrustedProxies([]string{"40.40.40.40", "30.30.30.30"}) + c.Request.Header.Set("X-Forwarded-For", "[::1]:9999, 20.20.20.20:8080, 30.30.30.30") + assert.Equal(t, "::1", c.ClientIP()) + // no port c.Request.RemoteAddr = "50.50.50.50" assert.Empty(t, c.ClientIP()) diff --git a/gin.go b/gin.go index 2e033bf347..5b5eaf4912 100644 --- a/gin.go +++ b/gin.go @@ -486,6 +486,7 @@ 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]) + ipStr = normalizeIP(ipStr) ip := net.ParseIP(ipStr) if ip == nil { break @@ -500,6 +501,25 @@ func (engine *Engine) validateHeader(header string) (clientIP string, valid bool return "", false } +// normalizeIP strips brackets and port from an IP address string. +// This handles non-standard X-Forwarded-For header content like: +// - IPv6 with brackets: [::1] or [2001:db8::1] +// - IPv4 with port: 192.168.1.1:8080 +// - IPv6 with brackets and port: [::1]:8080 +func normalizeIP(ipStr string) string { + // Try to split host and port (handles "ip:port" and "[ipv6]:port" formats) + if host, _, err := net.SplitHostPort(ipStr); err == nil { + return host + } + // If SplitHostPort fails, it might be an IPv6 with brackets only + // e.g., "[2001:db8::1]" - strip the brackets + if len(ipStr) > 1 && ipStr[0] == '[' && ipStr[len(ipStr)-1] == ']' { + return ipStr[1 : len(ipStr)-1] + } + // Return as-is for plain IPs (IPv4 or IPv6 without brackets) + return ipStr +} + // 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 a9cf1755f3..6b155b1a63 100644 --- a/gin_test.go +++ b/gin_test.go @@ -1156,3 +1156,38 @@ func TestUpdateRouteTreesCalledOnce(t *testing.T) { assert.Equal(t, "ok", w.Body.String()) } } + +// TestNormalizeIP tests the normalizeIP function for handling non-standard +// X-Forwarded-For header content (issue #4572) +func TestNormalizeIP(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + // Plain IPv4 + {name: "plain IPv4", input: "192.168.1.1", expected: "192.168.1.1"}, + // IPv4 with port + {name: "IPv4 with port", input: "192.168.1.1:8080", expected: "192.168.1.1"}, + // Plain IPv6 + {name: "plain IPv6", input: "::1", expected: "::1"}, + {name: "plain IPv6 full", input: "2001:db8::1", expected: "2001:db8::1"}, + // IPv6 with brackets only + {name: "IPv6 with brackets only", input: "[::1]", expected: "::1"}, + {name: "IPv6 with brackets full", input: "[2001:db8::1]", expected: "2001:db8::1"}, + // IPv6 with brackets and port + {name: "IPv6 with brackets and port", input: "[::1]:8080", expected: "::1"}, + {name: "IPv6 with brackets and port full", input: "[2001:db8::1]:8080", expected: "2001:db8::1"}, + // Empty string + {name: "empty string", input: "", expected: ""}, + // Invalid IP (return as-is for net.ParseIP to handle) + {name: "invalid IP", input: "not-an-ip", expected: "not-an-ip"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeIP(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +}