fix(engine): accept bracketed IPv6 and port-suffixed entries in X-Forwarded-For

`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
This commit is contained in:
BootstrapperSBL 2026-04-18 00:13:37 +08:00
parent d3ffc99852
commit 2dc3c02373
2 changed files with 63 additions and 2 deletions

33
gin.go
View File

@ -485,8 +485,8 @@ func (engine *Engine) validateHeader(header string) (clientIP string, valid bool
} }
items := strings.Split(header, ",") items := strings.Split(header, ",")
for i := len(items) - 1; i >= 0; i-- { for i := len(items) - 1; i >= 0; i-- {
ipStr := strings.TrimSpace(items[i]) item := strings.TrimSpace(items[i])
ip := net.ParseIP(ipStr) ipStr, ip := parseForwardedForItem(item)
if ip == nil { if ip == nil {
break break
} }
@ -500,6 +500,35 @@ func (engine *Engine) validateHeader(header string) (clientIP string, valid bool
return "", false 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 // updateRouteTree do update to the route tree recursively
func updateRouteTree(n *node) { func updateRouteTree(n *node) {
n.path = strings.ReplaceAll(n.path, escapedColon, colon) n.path = strings.ReplaceAll(n.path, escapedColon, colon)

View File

@ -1156,3 +1156,35 @@ func TestUpdateRouteTreesCalledOnce(t *testing.T) {
assert.Equal(t, "ok", w.Body.String()) 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)
})
}
}