From 496005d68812e442cf641b7728fff58133d12607 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:08:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8C=89=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=A4=B4=E8=A7=A3=E6=9E=90=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/internal/pkg/ip/ip.go | 104 ++++++++++++++++++++----- backend/internal/pkg/ip/ip_test.go | 117 +++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 20 deletions(-) diff --git a/backend/internal/pkg/ip/ip.go b/backend/internal/pkg/ip/ip.go index 2e74665b0b..ff1347d809 100644 --- a/backend/internal/pkg/ip/ip.go +++ b/backend/internal/pkg/ip/ip.go @@ -8,22 +8,46 @@ import ( "github.com/gin-gonic/gin" ) -const legacyForwardedIPTrustKey = "sub2api.legacy_forwarded_ip_trust" +const forwardedIPSettingsKey = "sub2api.forwarded_ip_settings" + +type forwardedIPSettings struct { + trustForwarded bool + headers []string +} + +// SetForwardedIPSettings snapshots the forwarded-IP mode and custom header list +// for this request. +func SetForwardedIPSettings(c *gin.Context, enabled bool, headers []string) { + if c == nil { + return + } + c.Set(forwardedIPSettingsKey, forwardedIPSettings{ + trustForwarded: enabled, + headers: append([]string(nil), headers...), + }) +} // SetLegacyForwardedIPTrust records whether raw forwarding headers override // Gin's server.trusted_proxies chain for this request. func SetLegacyForwardedIPTrust(c *gin.Context, enabled bool) { - if c != nil { - c.Set(legacyForwardedIPTrustKey, enabled) + SetForwardedIPSettings(c, enabled, nil) +} + +func requestForwardedIPSettings(c *gin.Context) (forwardedIPSettings, bool) { + if c == nil { + return forwardedIPSettings{}, false } + value, ok := c.Get(forwardedIPSettingsKey) + if !ok { + return forwardedIPSettings{}, false + } + settings, ok := value.(forwardedIPSettings) + return settings, ok } func requestUsesLegacyForwardedIPTrust(c *gin.Context) bool { - if c == nil { - return true - } - enabled, ok := c.Get(legacyForwardedIPTrustKey) - return !ok || enabled == true + settings, ok := requestForwardedIPSettings(c) + return !ok || settings.trustForwarded } // GetClientIP resolves the client address using the legacy forwarding-header @@ -38,15 +62,61 @@ func GetClientIP(c *gin.Context) string { return GetTrustedClientIP(c) } + settings, _ := requestForwardedIPSettings(c) + customIP, customFallback := resolveCustomForwardedClientIP(c, settings.headers) + if customIP != "" { + return customIP + } + // Preserve the historical precedence used by existing reverse-proxy // deployments, while skipping an internal proxy address when a public XFF // value is available. This covers Docker/Nginx setups that accidentally // write the bridge address into X-Real-IP. + legacyIP, legacyFallback := resolveLegacyForwardedHeaderIP(c) + if legacyIP != "" { + return legacyIP + } + if customFallback != "" { + return customFallback + } + if legacyFallback != "" { + return legacyFallback + } + return normalizeIP(c.ClientIP()) +} + +func resolveCustomForwardedClientIP(c *gin.Context, headers []string) (string, string) { + if c == nil { + return "", "" + } + var fallback string + for _, header := range headers { + for _, value := range c.Request.Header.Values(header) { + for _, candidate := range strings.Split(value, ",") { + parsed := net.ParseIP(strings.TrimSpace(candidate)) + if parsed == nil { + continue + } + normalized := parsed.String() + if isPrivateIP(normalized) { + if fallback == "" { + fallback = normalized + } + continue + } + return normalized, fallback + } + } + } + return "", fallback +} + +func resolveLegacyForwardedHeaderIP(c *gin.Context) (string, string) { var fallback string if forwarded := normalizeIP(c.GetHeader("CF-Connecting-IP")); forwarded != "" { fallback = forwarded if !isPrivateIP(forwarded) { - return forwarded + return forwarded, fallback } } if realIP := normalizeIP(c.GetHeader("X-Real-IP")); realIP != "" { @@ -54,7 +124,7 @@ func GetClientIP(c *gin.Context) string { fallback = realIP } if !isPrivateIP(realIP) { - return realIP + return realIP, fallback } } if xff := c.GetHeader("X-Forwarded-For"); xff != "" { @@ -62,18 +132,14 @@ func GetClientIP(c *gin.Context) string { for _, candidate := range ips { candidate = strings.TrimSpace(candidate) if candidate != "" && !isPrivateIP(candidate) { - return normalizeIP(candidate) + return normalizeIP(candidate), fallback } } if fallback == "" && len(ips) > 0 { fallback = normalizeIP(strings.TrimSpace(ips[0])) } } - if fallback != "" { - return fallback - } - - return normalizeIP(c.ClientIP()) + return "", fallback } // GetTrustedClientIP 从 Gin 的可信代理解析链提取客户端 IP。 @@ -91,10 +157,8 @@ func GetTrustedClientIP(c *gin.Context) string { // client-IP resolution. When disabled, Gin's server.trusted_proxies chain is // authoritative. func GetSecurityClientIP(c *gin.Context, trustForwarded bool) string { - if c != nil { - if requestTrust, ok := c.Get(legacyForwardedIPTrustKey); ok { - trustForwarded = requestTrust == true - } + if requestSettings, ok := requestForwardedIPSettings(c); ok { + trustForwarded = requestSettings.trustForwarded } if trustForwarded { return GetClientIP(c) diff --git a/backend/internal/pkg/ip/ip_test.go b/backend/internal/pkg/ip/ip_test.go index d4347cab5a..505ade5eea 100644 --- a/backend/internal/pkg/ip/ip_test.go +++ b/backend/internal/pkg/ip/ip_test.go @@ -92,6 +92,101 @@ func TestGetSecurityClientIPSwitchEnabledUsesLegacyHeaders(t *testing.T) { require.Equal(t, "1.2.3.4", w.Body.String()) } +func TestGetSecurityClientIPCustomHeaderPrecedenceAndFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + trustForward bool + headers []string + requestHeaders map[string]string + want string + }{ + { + name: "configured order precedes built-ins", + trustForward: true, + headers: []string{"X-CDN-First", "X-CDN-Second"}, + requestHeaders: map[string]string{ + "X-CDN-First": "198.51.100.10", + "X-CDN-Second": "203.0.113.20", + "CF-Connecting-IP": "8.8.8.8", + }, + want: "198.51.100.10", + }, + { + name: "comma candidates skip invalid and private values", + trustForward: true, + headers: []string{"X-CDN-First", "X-CDN-Second"}, + requestHeaders: map[string]string{ + "X-CDN-First": "not-an-ip, 10.0.0.8", + "X-CDN-Second": "also-bad, 203.0.113.9", + }, + want: "203.0.113.9", + }, + { + name: "legacy public header wins over custom private fallback", + trustForward: true, + headers: []string{"X-CDN-IP"}, + requestHeaders: map[string]string{ + "X-CDN-IP": "10.0.0.8", + "X-Real-IP": "1.2.3.4", + }, + want: "1.2.3.4", + }, + { + name: "custom private fallback retains configured precedence", + trustForward: true, + headers: []string{"X-CDN-IP"}, + requestHeaders: map[string]string{ + "X-CDN-IP": "10.0.0.8", + "X-Real-IP": "192.168.1.4", + }, + want: "10.0.0.8", + }, + { + name: "invalid custom value continues to built-ins", + trustForward: true, + headers: []string{"X-CDN-IP"}, + requestHeaders: map[string]string{ + "X-CDN-IP": "1.2.3.4:443", + "CF-Connecting-IP": "4.4.4.4", + }, + want: "4.4.4.4", + }, + { + name: "disabled mode ignores custom and legacy headers", + trustForward: false, + headers: []string{"X-CDN-IP"}, + requestHeaders: map[string]string{ + "X-CDN-IP": "1.2.3.4", + "X-Real-IP": "4.4.4.4", + }, + want: "9.9.9.9", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.GET("/t", func(c *gin.Context) { + SetForwardedIPSettings(c, test.trustForward, test.headers) + c.String(200, GetSecurityClientIP(c, !test.trustForward)) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/t", nil) + req.RemoteAddr = "9.9.9.9:12345" + for name, value := range test.requestHeaders { + req.Header.Set(name, value) + } + r.ServeHTTP(w, req) + + require.Equal(t, test.want, w.Body.String()) + }) + } +} + func TestGetSecurityClientIPSwitchDisabledUsesConfiguredTrustedProxy(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() @@ -125,6 +220,28 @@ func TestGetClientIPSwitchDisabledUsesTrustedProxyChain(t *testing.T) { require.Equal(t, "9.9.9.9", w.Body.String()) } +func TestGetSecurityClientIPRequestSnapshotCopiesCustomHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.GET("/t", func(c *gin.Context) { + headers := []string{"X-Original-IP"} + SetForwardedIPSettings(c, true, headers) + headers[0] = "X-Mutated-IP" + c.String(200, GetSecurityClientIP(c, false)) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/t", nil) + req.RemoteAddr = "9.9.9.9:12345" + req.Header.Set("X-Original-IP", "1.2.3.4") + req.Header.Set("X-Mutated-IP", "4.4.4.4") + r.ServeHTTP(w, req) + + require.Equal(t, "1.2.3.4", w.Body.String()) +} + func TestGetSecurityClientIPRequestSnapshotOverridesLiveFallback(t *testing.T) { gin.SetMode(gin.TestMode)