From 732aeef880103d36e37b1a4f0e843be22bcb4c3a Mon Sep 17 00:00:00 2001 From: Jlypx Date: Sun, 19 Jul 2026 19:41:01 +0800 Subject: [PATCH 01/23] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E5=8F=8D?= =?UTF-8?q?=E4=BB=A3=E5=92=8C=20Docker=20=E5=AE=A2=E6=88=B7=E7=AB=AF=20IP?= =?UTF-8?q?=20=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- README_CN.md | 2 +- README_JA.md | 2 +- backend/internal/config/config.go | 20 +++++- backend/internal/config/config_test.go | 17 +++++ backend/internal/pkg/ip/ip.go | 87 ++++++++++++++++++++++++-- backend/internal/pkg/ip/ip_test.go | 20 ++++++ deploy/EDGE_SECURITY.md | 20 +++--- deploy/config.example.yaml | 15 ++++- 9 files changed, 165 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index f48e09087b..1e34840b00 100644 --- a/README.md +++ b/README.md @@ -571,7 +571,7 @@ Additional security-related options are available in `config.yaml`: - `security.response_headers.enabled` to enable configurable response header filtering (disabled uses default allowlist) - `security.csp` to control Content-Security-Policy headers - `billing.circuit_breaker` to fail closed on billing errors -- `server.trusted_proxies` to enable X-Forwarded-For parsing +- `server.trusted_proxies` to configure forwarded-IP trust for security-sensitive paths (local/container proxy ranges are trusted by default; replace them with exact remote proxy CIDRs when needed) - `turnstile.required` to require Turnstile in release mode **⚠️ Security Warning: HTTP URL Configuration** diff --git a/README_CN.md b/README_CN.md index bc33ad82a0..632b6532db 100644 --- a/README_CN.md +++ b/README_CN.md @@ -607,7 +607,7 @@ gateway: - `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单) - `security.csp` 配置 Content-Security-Policy - `billing.circuit_breaker` 计费异常时 fail-closed -- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For +- `server.trusted_proxies` 配置安全敏感路径的反代 IP 信任(本机/常见 Docker 私网网段默认已信任;远程反代请替换为精确 CIDR) - `turnstile.required` 在 release 模式强制启用 Turnstile **网关防御纵深建议(重点)** diff --git a/README_JA.md b/README_JA.md index 90541cadf4..849d951f68 100644 --- a/README_JA.md +++ b/README_JA.md @@ -569,7 +569,7 @@ default: - `security.response_headers.enabled` - 設定可能なレスポンスヘッダーフィルタリングを有効化(無効時はデフォルトの許可リストを使用) - `security.csp` - Content-Security-Policy ヘッダーの制御 - `billing.circuit_breaker` - 課金エラー時にフェイルクローズ -- `server.trusted_proxies` - X-Forwarded-For パースの有効化 +- `server.trusted_proxies` - セキュリティ用途の転送 IP 信頼を設定(ローカル/一般的な Docker プライベート範囲はデフォルトで信頼。リモートプロキシは正確な CIDR に置換) - `turnstile.required` - リリースモードでの Turnstile 必須化 **⚠️ セキュリティ警告: HTTP URL 設定** diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 38ebc94273..c7afd5594c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -652,6 +652,21 @@ type ServerConfig struct { H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置 } +// defaultTrustedProxies covers local and container-network reverse proxies. +// It keeps a fresh installation usable without weakening trust to every +// network address; deployments with a public/private load balancer should +// replace it with the exact proxy CIDRs. +func defaultTrustedProxies() []string { + return []string{ + "127.0.0.0/8", + "::1/128", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7", + } +} + // H2CConfig HTTP/2 Cleartext 配置 type H2CConfig struct { Enabled bool `mapstructure:"enabled"` // 是否启用 H2C @@ -1714,7 +1729,10 @@ func setDefaults() { viper.SetDefault("server.read_header_timeout", 10) // 10秒读取请求头 viper.SetDefault("server.max_header_bytes", 64*1024) viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时 - viper.SetDefault("server.trusted_proxies", []string{}) + // Trust local/container reverse proxies by default so existing deployments + // keep working without a config migration. An explicit list still replaces + // this default, and an explicit empty list disables the trust chain. + viper.SetDefault("server.trusted_proxies", defaultTrustedProxies()) viper.SetDefault("server.max_request_body_size", int64(256*1024*1024)) // H2C 默认配置 viper.SetDefault("server.h2c.enabled", false) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 36ae89ffa4..2356d724a1 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -41,12 +41,29 @@ func TestLoadHTTPIngressSafetyDefaults(t *testing.T) { require.NoError(t, err) require.Equal(t, 10, cfg.Server.ReadHeaderTimeout) require.Equal(t, 64*1024, cfg.Server.MaxHeaderBytes) + require.Equal(t, []string{ + "127.0.0.0/8", + "::1/128", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7", + }, cfg.Server.TrustedProxies) require.Equal(t, int64(32*1024*1024), cfg.Gateway.TextMaxBodySize) require.True(t, cfg.APIKeyAuth.InvalidAbuse.Enabled) require.Equal(t, 120, cfg.APIKeyAuth.InvalidAbuse.Threshold) require.Equal(t, 16384, cfg.APIKeyAuth.InvalidAbuse.Capacity) } +func TestLoadExplicitEmptyTrustedProxiesKeepsLegacyDefault(t *testing.T) { + resetViperWithJWTSecret(t) + viper.Set("server.trusted_proxies", []string{}) + + cfg, err := Load() + require.NoError(t, err) + require.Empty(t, cfg.Server.TrustedProxies) +} + func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) { viper.Reset() t.Setenv("JWT_SECRET", "") diff --git a/backend/internal/pkg/ip/ip.go b/backend/internal/pkg/ip/ip.go index b9c7774490..e24f11bff4 100644 --- a/backend/internal/pkg/ip/ip.go +++ b/backend/internal/pkg/ip/ip.go @@ -8,10 +8,51 @@ import ( "github.com/gin-gonic/gin" ) -// GetClientIP resolves a client address only through Gin's configured trusted -// proxy chain. Forwarding headers from a direct or untrusted peer are ignored. +// GetClientIP resolves the client address using the legacy forwarding-header +// precedence used before the trusted-proxy hardening. It remains the +// compatibility path for request metadata and usage/error logs; security- +// sensitive callers must use GetTrustedClientIP or GetSecurityClientIP. func GetClientIP(c *gin.Context) string { - return GetTrustedClientIP(c) + if c == nil { + return "" + } + + // 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. + var fallback string + if forwarded := normalizeIP(c.GetHeader("CF-Connecting-IP")); forwarded != "" { + fallback = forwarded + if !isPrivateIP(forwarded) { + return forwarded + } + } + if realIP := normalizeIP(c.GetHeader("X-Real-IP")); realIP != "" { + if fallback == "" { + fallback = realIP + } + if !isPrivateIP(realIP) { + return realIP + } + } + if xff := c.GetHeader("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + for _, candidate := range ips { + candidate = strings.TrimSpace(candidate) + if candidate != "" && !isPrivateIP(candidate) { + return normalizeIP(candidate) + } + } + if fallback == "" && len(ips) > 0 { + fallback = normalizeIP(strings.TrimSpace(ips[0])) + } + } + if fallback != "" { + return fallback + } + + return normalizeIP(c.ClientIP()) } // GetTrustedClientIP 从 Gin 的可信代理解析链提取客户端 IP。 @@ -24,9 +65,9 @@ func GetTrustedClientIP(c *gin.Context) string { return normalizeIP(c.ClientIP()) } -// GetSecurityClientIP returns the address resolved through Gin's configured -// trusted-proxy chain. The legacy toggle is retained for configuration/API -// compatibility, but never makes raw forwarding headers trustworthy by itself. +// GetSecurityClientIP returns the address used by security-sensitive paths. +// The legacy toggle remains in the signature for configuration/API +// compatibility, but cannot make raw forwarding headers trustworthy by itself. func GetSecurityClientIP(c *gin.Context, _ bool) string { return GetTrustedClientIP(c) } @@ -41,6 +82,27 @@ func normalizeIP(ip string) string { return ip } +// privateNets contains the private/loopback ranges skipped while selecting a +// public address from a legacy X-Forwarded-For chain. +var privateNets []*net.IPNet + +func init() { + for _, cidr := range []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "::1/128", + "fc00::/7", + } { + _, block, err := net.ParseCIDR(cidr) + if err != nil { + panic("invalid CIDR: " + cidr) + } + privateNets = append(privateNets, block) + } +} + // CompiledIPRules 表示预编译的 IP 匹配规则。 // PatternCount 记录原始规则数量,用于保留“规则存在但全无效”时的行为语义。 type CompiledIPRules struct { @@ -96,6 +158,19 @@ func matchesCompiledRules(parsedIP net.IP, rules *CompiledIPRules) bool { return false } +func isPrivateIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + for _, block := range privateNets { + if block.Contains(ip) { + return true + } + } + return false +} + // MatchesPattern 检查 IP 是否匹配指定的模式(支持单个 IP 或 CIDR)。 // pattern 可以是: // - 单个 IP: "192.168.1.100" diff --git a/backend/internal/pkg/ip/ip_test.go b/backend/internal/pkg/ip/ip_test.go index 2432abe7ab..f3ddddeb69 100644 --- a/backend/internal/pkg/ip/ip_test.go +++ b/backend/internal/pkg/ip/ip_test.go @@ -32,6 +32,26 @@ func TestGetTrustedClientIPUsesGinClientIP(t *testing.T) { require.Equal(t, "9.9.9.9", w.Body.String()) } +func TestGetClientIPPreservesLegacyDockerForwardedHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.GET("/t", func(c *gin.Context) { + c.String(200, GetClientIP(c)) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/t", nil) + req.RemoteAddr = "192.168.32.1:12345" + req.Header.Set("X-Forwarded-For", "10.0.0.2, 203.0.113.42") + req.Header.Set("X-Real-IP", "192.168.32.1") + r.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + require.Equal(t, "203.0.113.42", w.Body.String()) +} + func TestCheckIPRestrictionWithCompiledRules(t *testing.T) { whitelist := CompileIPRules([]string{"10.0.0.0/8", "192.168.1.2"}) blacklist := CompileIPRules([]string{"10.1.1.1"}) diff --git a/deploy/EDGE_SECURITY.md b/deploy/EDGE_SECURITY.md index 0054253eb0..b2c08cf20c 100644 --- a/deploy/EDGE_SECURITY.md +++ b/deploy/EDGE_SECURITY.md @@ -29,15 +29,21 @@ the application's responsibility. ## Trusted client IPs -`server.trusted_proxies` must contain only the CIDR/IP addresses that connect -directly to Sub2API, normally the local Nginx/Caddy address or the private load -balancer subnet. An empty list disables forwarded-IP trust. +`server.trusted_proxies` controls forwarded-IP trust for security-sensitive +paths such as API-key ACLs, session binding, and rejection aggregation. Fresh +installations default to local/container ranges (`127.0.0.0/8`, `::1/128`, +`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, and `fc00::/7`) so a local +Nginx/Caddy or Docker bridge works without a migration. For a remote load +balancer, replace the defaults with only the CIDRs that connect directly to +Sub2API. An explicit empty list disables forwarded-IP trust for these paths; +ordinary request/usage metadata keeps its legacy compatibility behavior. -Never trust `CF-Connecting-IP`, `X-Real-IP`, or `X-Forwarded-For` merely because -the header exists. A CDN deployment must firewall the origin so only the CDN or -load balancer can reach it, and the proxy must overwrite forwarded headers. +Never use `CF-Connecting-IP`, `X-Real-IP`, or `X-Forwarded-For` for an ACL or +session decision merely because the header exists. A CDN deployment must +firewall the origin so only the CDN or load balancer can reach it, and the proxy +must overwrite forwarded headers. -Example for a proxy on the same host: +Example for a proxy on the same host (the default already covers this case): ```yaml server: diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 24fa445158..2720c2d298 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -36,9 +36,18 @@ server: # Keep-alive idle timeout in seconds. # Keep-Alive 空闲连接超时(秒)。 idle_timeout: 120 - # Trusted proxies for X-Forwarded-For parsing (CIDR/IP). Empty disables trusted proxies. - # 信任的代理地址(CIDR/IP 格式),用于解析 X-Forwarded-For 头。留空则禁用代理信任。 - trusted_proxies: [] + # Trusted proxies for security-sensitive X-Forwarded-For parsing (CIDR/IP). + # These local/container ranges are the default; replace them with the exact + # proxy CIDRs for a remote load balancer. Set [] explicitly to disable trust. + # 安全敏感场景解析 X-Forwarded-For 的可信代理(CIDR/IP)。以下为本机/容器 + # 网段默认值;远程负载均衡请替换为实际 CIDR。显式设置 [] 可禁用代理信任。 + trusted_proxies: + - 127.0.0.0/8 + - ::1/128 + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + - fc00::/7 # Global max request body size in bytes (default: 256MB) # 全局最大请求体大小(字节,默认 256MB) # Applies to all requests, especially important for h2c first request memory protection From 8a147fcc513f9bc83cd8b964a16fcb90da48e05e Mon Sep 17 00:00:00 2001 From: Jlypx Date: Sun, 19 Jul 2026 21:39:24 +0800 Subject: [PATCH 02/23] =?UTF-8?q?fix:=20=E8=A7=A3=E6=9E=90=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E5=8F=AF=E4=BF=A1=E4=BB=A3=E7=90=86=E9=85=8D=E7=BD=AE?= 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/config/config.go | 73 ++++++++++++------------- backend/internal/config/config_test.go | 76 +++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 47 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index c7afd5594c..6aef080721 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -639,32 +639,18 @@ type PricingConfig struct { } type ServerConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - Mode string `mapstructure:"mode"` // debug/release - EnableServerTiming bool `mapstructure:"enable_server_timing"` // Admin UI Server-Timing response header - FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL,用于生成邮件中的外部链接 - ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒) - MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 请求头最大字节数(HTTP/2 映射为 header-list 上限) - IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒) - TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表(CIDR/IP) - MaxRequestBodySize int64 `mapstructure:"max_request_body_size"` // 全局最大请求体限制 - H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置 -} - -// defaultTrustedProxies covers local and container-network reverse proxies. -// It keeps a fresh installation usable without weakening trust to every -// network address; deployments with a public/private load balancer should -// replace it with the exact proxy CIDRs. -func defaultTrustedProxies() []string { - return []string{ - "127.0.0.0/8", - "::1/128", - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "fc00::/7", - } + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Mode string `mapstructure:"mode"` // debug/release + EnableServerTiming bool `mapstructure:"enable_server_timing"` // Admin UI Server-Timing response header + FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL,用于生成邮件中的外部链接 + ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒) + MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 请求头最大字节数(HTTP/2 映射为 header-list 上限) + IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒) + TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表(CIDR/IP) + TrustedProxiesConfigured bool `mapstructure:"-" json:"-" yaml:"-"` // 是否显式配置了可信代理列表 + MaxRequestBodySize int64 `mapstructure:"max_request_body_size"` // 全局最大请求体限制 + H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置 } // H2CConfig HTTP/2 Cleartext 配置 @@ -683,13 +669,15 @@ type CORSConfig struct { } type SecurityConfig struct { - URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"` - ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"` - CSP CSPConfig `mapstructure:"csp"` - ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"` - ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"` - TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"` - trustForwardedIPForAPIKeyACLLive *atomic.Bool `mapstructure:"-"` + URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"` + ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"` + CSP CSPConfig `mapstructure:"csp"` + ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"` + ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"` + // TrustForwardedIPForAPIKeyACL enables legacy raw forwarded-header takeover. + // When disabled, server.trusted_proxies is authoritative for all client-IP consumers. + TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"` + trustForwardedIPForAPIKeyACLLive *atomic.Bool `mapstructure:"-"` } func (c *Config) TrustForwardedIPForAPIKeyACL() bool { @@ -703,6 +691,12 @@ func (c *Config) TrustForwardedIPForAPIKeyACL() bool { return live.Load() } +// ForwardedClientIPTrustEnabled reports whether the legacy forwarded-header +// compatibility mode currently overrides server.trusted_proxies. +func (c *Config) ForwardedClientIPTrustEnabled() bool { + return c != nil && c.TrustForwardedIPForAPIKeyACL() +} + func (c *Config) SetTrustForwardedIPForAPIKeyACL(enabled bool) { if c == nil { return @@ -1579,11 +1573,18 @@ func load(allowMissingJWTSecret bool) (*Config, error) { } // 配置文件不存在时使用默认值 } + trustedProxiesEnv, trustedProxiesEnvConfigured := os.LookupEnv("SERVER_TRUSTED_PROXIES") + trustedProxiesConfigured := viper.InConfig("server.trusted_proxies") || + viper.IsSet("server.trusted_proxies") || trustedProxiesEnvConfigured var cfg Config if err := viper.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("unmarshal config error: %w", err) } + if trustedProxiesEnvConfigured { + cfg.Server.TrustedProxies = normalizeStringSlice(strings.Split(trustedProxiesEnv, ",")) + } + cfg.Server.TrustedProxiesConfigured = trustedProxiesConfigured if cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs == 0 { cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000 } @@ -1729,10 +1730,6 @@ func setDefaults() { viper.SetDefault("server.read_header_timeout", 10) // 10秒读取请求头 viper.SetDefault("server.max_header_bytes", 64*1024) viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时 - // Trust local/container reverse proxies by default so existing deployments - // keep working without a config migration. An explicit list still replaces - // this default, and an explicit empty list disables the trust chain. - viper.SetDefault("server.trusted_proxies", defaultTrustedProxies()) viper.SetDefault("server.max_request_body_size", int64(256*1024*1024)) // H2C 默认配置 viper.SetDefault("server.h2c.enabled", false) @@ -1789,7 +1786,7 @@ func setDefaults() { viper.SetDefault("security.csp.enabled", true) viper.SetDefault("security.csp.policy", DefaultCSPPolicy) viper.SetDefault("security.proxy_probe.insecure_skip_verify", false) - viper.SetDefault("security.trust_forwarded_ip_for_api_key_acl", false) + viper.SetDefault("security.trust_forwarded_ip_for_api_key_acl", true) // Security - disable direct fallback on proxy error viper.SetDefault("security.proxy_fallback.allow_direct_on_error", false) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 2356d724a1..20da10a6a7 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -41,27 +41,85 @@ func TestLoadHTTPIngressSafetyDefaults(t *testing.T) { require.NoError(t, err) require.Equal(t, 10, cfg.Server.ReadHeaderTimeout) require.Equal(t, 64*1024, cfg.Server.MaxHeaderBytes) - require.Equal(t, []string{ - "127.0.0.0/8", - "::1/128", - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "fc00::/7", - }, cfg.Server.TrustedProxies) + require.Empty(t, cfg.Server.TrustedProxies) + require.False(t, cfg.Server.TrustedProxiesConfigured) + require.True(t, cfg.TrustForwardedIPForAPIKeyACL()) require.Equal(t, int64(32*1024*1024), cfg.Gateway.TextMaxBodySize) require.True(t, cfg.APIKeyAuth.InvalidAbuse.Enabled) require.Equal(t, 120, cfg.APIKeyAuth.InvalidAbuse.Threshold) require.Equal(t, 16384, cfg.APIKeyAuth.InvalidAbuse.Capacity) } -func TestLoadExplicitEmptyTrustedProxiesKeepsLegacyDefault(t *testing.T) { +func TestLoadExplicitEmptyTrustedProxiesEnablesConfiguredMode(t *testing.T) { resetViperWithJWTSecret(t) viper.Set("server.trusted_proxies", []string{}) cfg, err := Load() require.NoError(t, err) require.Empty(t, cfg.Server.TrustedProxies) + require.True(t, cfg.Server.TrustedProxiesConfigured) +} + +func TestLoadExplicitTrustedProxiesEnablesConfiguredMode(t *testing.T) { + resetViperWithJWTSecret(t) + viper.Set("server.trusted_proxies", []string{"127.0.0.1/32"}) + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, []string{"127.0.0.1/32"}, cfg.Server.TrustedProxies) + require.True(t, cfg.Server.TrustedProxiesConfigured) +} + +func TestLoadTrustedProxiesFromEnvironment(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("SERVER_TRUSTED_PROXIES", "127.0.0.1/32, ::1/128") + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, []string{"127.0.0.1/32", "::1/128"}, cfg.Server.TrustedProxies) + require.True(t, cfg.Server.TrustedProxiesConfigured) +} + +func TestLoadExplicitEmptyTrustedProxiesFromEnvironment(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("SERVER_TRUSTED_PROXIES", "") + + cfg, err := Load() + require.NoError(t, err) + require.Empty(t, cfg.Server.TrustedProxies) + require.True(t, cfg.Server.TrustedProxiesConfigured) +} + +func TestLoadTrustedProxiesPresenceFromYAML(t *testing.T) { + tests := []struct { + name string + yaml string + want []string + configured bool + }{ + {name: "absent", yaml: "server:\n mode: debug\n", configured: false}, + {name: "explicit empty", yaml: "server:\n trusted_proxies: []\n", want: []string{}, configured: true}, + { + name: "populated", + yaml: "server:\n trusted_proxies:\n - 127.0.0.1/32\n - ::1/128\n", + want: []string{"127.0.0.1/32", "::1/128"}, + configured: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resetViperWithJWTSecret(t) + configDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(test.yaml), 0o600)) + t.Setenv("DATA_DIR", configDir) + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, test.want, cfg.Server.TrustedProxies) + require.Equal(t, test.configured, cfg.Server.TrustedProxiesConfigured) + }) + } } func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) { From 6e2ba0e4cdbfde86c6d85f67638adb6a8b87f0fe Mon Sep 17 00:00:00 2001 From: Jlypx Date: Sun, 19 Jul 2026 21:39:49 +0800 Subject: [PATCH 03/23] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E7=BA=A7=E5=AE=A2=E6=88=B7=E7=AB=AF=20IP=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= 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 | 36 +++++++++++- backend/internal/pkg/ip/ip_test.go | 92 +++++++++++++++++++++--------- 2 files changed, 99 insertions(+), 29 deletions(-) diff --git a/backend/internal/pkg/ip/ip.go b/backend/internal/pkg/ip/ip.go index e24f11bff4..2e74665b0b 100644 --- a/backend/internal/pkg/ip/ip.go +++ b/backend/internal/pkg/ip/ip.go @@ -8,6 +8,24 @@ import ( "github.com/gin-gonic/gin" ) +const legacyForwardedIPTrustKey = "sub2api.legacy_forwarded_ip_trust" + +// 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) + } +} + +func requestUsesLegacyForwardedIPTrust(c *gin.Context) bool { + if c == nil { + return true + } + enabled, ok := c.Get(legacyForwardedIPTrustKey) + return !ok || enabled == true +} + // GetClientIP resolves the client address using the legacy forwarding-header // precedence used before the trusted-proxy hardening. It remains the // compatibility path for request metadata and usage/error logs; security- @@ -16,6 +34,9 @@ func GetClientIP(c *gin.Context) string { if c == nil { return "" } + if !requestUsesLegacyForwardedIPTrust(c) { + return GetTrustedClientIP(c) + } // Preserve the historical precedence used by existing reverse-proxy // deployments, while skipping an internal proxy address when a public XFF @@ -66,9 +87,18 @@ func GetTrustedClientIP(c *gin.Context) string { } // GetSecurityClientIP returns the address used by security-sensitive paths. -// The legacy toggle remains in the signature for configuration/API -// compatibility, but cannot make raw forwarding headers trustworthy by itself. -func GetSecurityClientIP(c *gin.Context, _ bool) string { +// When legacy forwarded-IP trust is enabled, raw forwarding headers take over +// 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 trustForwarded { + return GetClientIP(c) + } return GetTrustedClientIP(c) } diff --git a/backend/internal/pkg/ip/ip_test.go b/backend/internal/pkg/ip/ip_test.go index f3ddddeb69..d4347cab5a 100644 --- a/backend/internal/pkg/ip/ip_test.go +++ b/backend/internal/pkg/ip/ip_test.go @@ -73,41 +73,30 @@ func TestCheckIPRestrictionWithCompiledRules_InvalidWhitelistStillDenies(t *test require.Equal(t, "access denied", reason) } -func TestGetSecurityClientIPNeverTrustsHeadersFromUntrustedPeer(t *testing.T) { +func TestGetSecurityClientIPSwitchEnabledUsesLegacyHeaders(t *testing.T) { gin.SetMode(gin.TestMode) - for _, tc := range []struct { - name string - trustForwarded bool - want string - }{ - {name: "legacy toggle disabled", trustForwarded: false, want: "9.9.9.9"}, - {name: "legacy toggle enabled", trustForwarded: true, want: "9.9.9.9"}, - } { - t.Run(tc.name, func(t *testing.T) { - r := gin.New() - require.NoError(t, r.SetTrustedProxies(nil)) - r.GET("/t", func(c *gin.Context) { - c.String(200, GetSecurityClientIP(c, tc.trustForwarded)) - }) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.GET("/t", func(c *gin.Context) { + c.String(200, GetSecurityClientIP(c, true)) + }) - w := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/t", nil) - req.RemoteAddr = "9.9.9.9:12345" - req.Header.Set("X-Real-IP", "1.2.3.4") - r.ServeHTTP(w, req) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/t", nil) + req.RemoteAddr = "9.9.9.9:12345" + req.Header.Set("X-Real-IP", "1.2.3.4") + r.ServeHTTP(w, req) - require.Equal(t, 200, w.Code) - require.Equal(t, tc.want, w.Body.String()) - }) - } + require.Equal(t, 200, w.Code) + require.Equal(t, "1.2.3.4", w.Body.String()) } -func TestGetSecurityClientIPUsesConfiguredTrustedProxy(t *testing.T) { +func TestGetSecurityClientIPSwitchDisabledUsesConfiguredTrustedProxy(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() require.NoError(t, r.SetTrustedProxies([]string{"9.9.9.9"})) - r.GET("/t", func(c *gin.Context) { c.String(200, GetSecurityClientIP(c, true)) }) + r.GET("/t", func(c *gin.Context) { c.String(200, GetSecurityClientIP(c, false)) }) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/t", nil) @@ -117,3 +106,54 @@ func TestGetSecurityClientIPUsesConfiguredTrustedProxy(t *testing.T) { require.Equal(t, "1.2.3.4", w.Body.String()) } + +func TestGetClientIPSwitchDisabledUsesTrustedProxyChain(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.GET("/t", func(c *gin.Context) { + SetLegacyForwardedIPTrust(c, false) + c.String(200, GetClientIP(c)) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/t", nil) + req.RemoteAddr = "9.9.9.9:12345" + req.Header.Set("X-Real-IP", "1.2.3.4") + r.ServeHTTP(w, req) + + require.Equal(t, "9.9.9.9", w.Body.String()) +} + +func TestGetSecurityClientIPRequestSnapshotOverridesLiveFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + requestTrust bool + fallbackTrust bool + want string + }{ + {name: "captured secure mode wins", requestTrust: false, fallbackTrust: true, want: "9.9.9.9"}, + {name: "captured compatibility mode wins", requestTrust: true, fallbackTrust: false, want: "1.2.3.4"}, + } + + 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) { + SetLegacyForwardedIPTrust(c, test.requestTrust) + c.String(200, GetSecurityClientIP(c, test.fallbackTrust)) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/t", nil) + req.RemoteAddr = "9.9.9.9:12345" + req.Header.Set("X-Real-IP", "1.2.3.4") + r.ServeHTTP(w, req) + + require.Equal(t, test.want, w.Body.String()) + }) + } +} From 39107ca4570c05e5779f49e127d819d1d1dce7a0 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Sun, 19 Jul 2026 21:40:32 +0800 Subject: [PATCH 04/23] =?UTF-8?q?fix:=20=E5=AE=89=E5=85=A8=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=20Gin=20=E5=8F=AF=E4=BF=A1=E4=BB=A3=E7=90=86?= 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/server/http.go | 32 ++++++++----- backend/internal/server/http_ingress_test.go | 50 ++++++++++++++++++++ 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index 65548c9e24..fef7cd056e 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -47,18 +47,7 @@ func ProvideRouter( r := gin.New() r.Use(middleware2.Recovery()) - if len(cfg.Server.TrustedProxies) > 0 { - if err := r.SetTrustedProxies(cfg.Server.TrustedProxies); err != nil { - log.Printf("Failed to set trusted proxies: %v", err) - } - } else { - if err := r.SetTrustedProxies(nil); err != nil { - log.Printf("Failed to disable trusted proxies: %v", err) - } - if cfg.Server.Mode == "release" { - log.Printf("Warning: server.trusted_proxies is empty in release mode; client IP trust chain is disabled") - } - } + configureTrustedProxies(r, cfg.Server) // Wire up websearch Manager builder so it initializes on startup and rebuilds on config save. settingService.SetWebSearchManagerBuilder(context.Background(), func(cfg *service.WebSearchEmulationConfig, proxyURLs map[int64]string) { @@ -99,6 +88,25 @@ func ProvideRouter( return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient) } +func configureTrustedProxies(r *gin.Engine, cfg config.ServerConfig) { + if cfg.TrustedProxiesConfigured { + if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil { + log.Printf("Failed to set trusted proxies: %v", err) + _ = r.SetTrustedProxies(nil) + } + if len(cfg.TrustedProxies) == 0 && cfg.Mode == "release" { + log.Printf("Warning: server.trusted_proxies is explicitly empty; forwarded client IP trust is disabled") + } + } else { + if err := r.SetTrustedProxies(nil); err != nil { + log.Printf("Failed to disable trusted proxies: %v", err) + } + if cfg.Mode == "release" { + log.Printf("Warning: server.trusted_proxies is not configured; disabling the forwarded-IP compatibility switch will use direct peer addresses only") + } + } +} + // ProvideHTTPServer 提供 HTTP 服务器 func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server { httpHandler := http.Handler(router) diff --git a/backend/internal/server/http_ingress_test.go b/backend/internal/server/http_ingress_test.go index 63cf7a047a..5e986bbd7c 100644 --- a/backend/internal/server/http_ingress_test.go +++ b/backend/internal/server/http_ingress_test.go @@ -55,6 +55,56 @@ func TestProvideHTTPServerEnablesBoundedH2C(t *testing.T) { require.True(t, srv.Protocols.HTTP1()) } +func TestConfigureTrustedProxies(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + cfg config.ServerConfig + want string + }{ + { + name: "configured proxy resolves forwarded client", + cfg: config.ServerConfig{ + TrustedProxies: []string{"9.9.9.9/32"}, + TrustedProxiesConfigured: true, + }, + want: "1.2.3.4", + }, + { + name: "explicit empty list ignores forwarded client", + cfg: config.ServerConfig{ + TrustedProxiesConfigured: true, + }, + want: "9.9.9.9", + }, + { + name: "invalid proxy list fails closed", + cfg: config.ServerConfig{ + TrustedProxies: []string{"not-a-cidr"}, + TrustedProxiesConfigured: true, + }, + want: "9.9.9.9", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := gin.New() + configureTrustedProxies(r, tc.cfg) + r.GET("/t", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/t", nil) + req.RemoteAddr = "9.9.9.9:12345" + req.Header.Set("X-Forwarded-For", "1.2.3.4") + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, tc.want, w.Body.String()) + }) + } +} + func TestHTTPServerRejectsOversizedHTTP1Header(t *testing.T) { r := gin.New() r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) From 6aa6b3a96832d23298502d65104bcce7a0c78120 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Sun, 19 Jul 2026 21:41:12 +0800 Subject: [PATCH 05/23] =?UTF-8?q?fix:=20=E5=B0=86=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=20IP=20=E6=A8=A1=E5=BC=8F=E6=B3=A8=E5=85=A5=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E4=B8=8A=E4=B8=8B=E6=96=87?= 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 --- .../internal/server/middleware/api_key_auth_test.go | 7 ++++--- .../internal/server/middleware/session_binding.go | 13 +++++++------ .../server/middleware/session_binding_test.go | 10 ++++++---- backend/internal/server/router.go | 2 +- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index a3216f8c22..4d6d9b5240 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -863,7 +863,7 @@ func TestRequireGroupAssignmentMarksUngroupedKeyBusinessLimited(t *testing.T) { require.Equal(t, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnassigned, businessLimitedReason) } -func TestAPIKeyAuthIPRestrictionDoesNotTrustForwardedClientIPByDefault(t *testing.T) { +func TestAPIKeyAuthIPRestrictionUsesTrustedPathWhenSwitchDisabled(t *testing.T) { gin.SetMode(gin.TestMode) user := &service.User{ @@ -893,6 +893,7 @@ func TestAPIKeyAuthIPRestrictionDoesNotTrustForwardedClientIPByDefault(t *testin } cfg := &config.Config{RunMode: config.RunModeSimple} + cfg.SetTrustForwardedIPForAPIKeyACL(false) apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) router := gin.New() require.NoError(t, router.SetTrustedProxies(nil)) @@ -1003,7 +1004,7 @@ func TestAPIKeyAuthIPRestrictionUsesConfiguredTrustedProxy(t *testing.T) { } cfg := &config.Config{RunMode: config.RunModeSimple} - cfg.SetTrustForwardedIPForAPIKeyACL(true) + cfg.SetTrustForwardedIPForAPIKeyACL(false) apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) router := gin.New() require.NoError(t, router.SetTrustedProxies([]string{"9.9.9.9"})) @@ -1054,7 +1055,7 @@ func TestAPIKeyAuthIPRestrictionUsesForwardedClientIPInDenialWhenTrusted(t *test } cfg := &config.Config{RunMode: config.RunModeSimple} - cfg.SetTrustForwardedIPForAPIKeyACL(true) + cfg.SetTrustForwardedIPForAPIKeyACL(false) apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) router := gin.New() require.NoError(t, router.SetTrustedProxies([]string{"9.9.9.9"})) diff --git a/backend/internal/server/middleware/session_binding.go b/backend/internal/server/middleware/session_binding.go index e535693c27..3f4d04b935 100644 --- a/backend/internal/server/middleware/session_binding.go +++ b/backend/internal/server/middleware/session_binding.go @@ -13,14 +13,16 @@ import ( // SessionBindingContext 全局中间件:将请求的客户端 IP 与 User-Agent 注入 // request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定, // 同时作为审计日志、会话绑定校验的统一客户端 IP 来源。 -// IP 取值与 API Key IP 限制共用 Gin trusted_proxies 解析链;旧设置开关 -// 仅为配置兼容保留,不能单独使直连请求的转发头变为可信。 +// IP 取值与 API Key IP 限制共用转发 IP 开关:开启时旧版原始转发头逻辑 +// 接管解析,关闭时使用 Gin 的 server.trusted_proxies 可信代理链。 func SessionBindingContext(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { + trustForwarded := cfg.TrustForwardedIPForAPIKeyACL() + ip.SetLegacyForwardedIPTrust(c, trustForwarded) userAgent := normalizePersistentText(c.Request.UserAgent(), maxPersistentUserAgentBytes) c.Request.Header.Set("User-Agent", userAgent) binding := &service.SessionBinding{ - IP: ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL()), + IP: ip.GetSecurityClientIP(c, trustForwarded), UserAgent: userAgent, } c.Request = c.Request.WithContext(service.WithSessionBinding(c.Request.Context(), binding)) @@ -29,8 +31,7 @@ func SessionBindingContext(cfg *config.Config) gin.HandlerFunc { } // requestSessionBinding 返回当前请求的会话指纹,优先取 SessionBindingContext -// 注入的解析结果(保证与 token 签发路径取值一致);注入缺失时按 trusted_proxies -// 链回退兜底(等价于开关关闭时的行为)。 +// 注入的解析结果(保证与 token 签发路径取值一致);注入缺失时使用安全回退。 func requestSessionBinding(c *gin.Context) *service.SessionBinding { if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil { return binding @@ -42,7 +43,7 @@ func requestSessionBinding(c *gin.Context) *service.SessionBinding { } // SecurityClientIP 返回当前请求用于安全敏感记录(审计日志等)的客户端 IP。 -// 与会话绑定、API Key IP 限制共用同一套「信任反代传递的客户端 IP」开关语义。 +// 与会话绑定、API Key IP 限制共用同一套客户端 IP 来源。 func SecurityClientIP(c *gin.Context) string { if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil && strings.TrimSpace(binding.IP) != "" { diff --git a/backend/internal/server/middleware/session_binding_test.go b/backend/internal/server/middleware/session_binding_test.go index 839983623d..11a098d9fd 100644 --- a/backend/internal/server/middleware/session_binding_test.go +++ b/backend/internal/server/middleware/session_binding_test.go @@ -14,23 +14,25 @@ import ( "github.com/stretchr/testify/require" ) -func TestSessionBindingContextDoesNotTrustHeadersWithoutTrustedProxy(t *testing.T) { +func TestSessionBindingContextFollowsForwardedIPSwitch(t *testing.T) { gin.SetMode(gin.TestMode) for _, tc := range []struct { name string trustForwarded bool + trustedProxies []string wantIP string }{ - {name: "trust disabled records proxy address", trustForwarded: false, wantIP: "127.0.0.1"}, - {name: "legacy trust toggle cannot bypass trusted proxies", trustForwarded: true, wantIP: "127.0.0.1"}, + {name: "enabled switch takes over raw headers", trustForwarded: true, wantIP: "1.2.3.4"}, + {name: "disabled switch ignores untrusted headers", trustForwarded: false, wantIP: "127.0.0.1"}, + {name: "disabled switch uses configured Gin proxy", trustForwarded: false, trustedProxies: []string{"127.0.0.1"}, wantIP: "1.2.3.4"}, } { t.Run(tc.name, func(t *testing.T) { cfg := &config.Config{} cfg.SetTrustForwardedIPForAPIKeyACL(tc.trustForwarded) r := gin.New() - require.NoError(t, r.SetTrustedProxies(nil)) + require.NoError(t, r.SetTrustedProxies(tc.trustedProxies)) r.Use(SessionBindingContext(cfg)) r.GET("/t", func(c *gin.Context) { binding := service.SessionBindingFromContext(c.Request.Context()) diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 47bffbef22..e10672d83e 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -56,7 +56,7 @@ func SetupRouter( // 应用中间件 r.Use(middleware2.RequestLogger()) // 将客户端 IP + UA 注入 request context,供 token 签发/会话绑定/审计日志统一读取。 - // IP 取值与 API Key IP 限制共用 server.trusted_proxies 信任链。 + // 解析模式按请求快照:兼容开关开启时信任原始转发头,关闭时使用 server.trusted_proxies。 r.Use(middleware2.SessionBindingContext(cfg)) r.Use(middleware2.Logger()) r.Use(middleware2.CORS(cfg.CORS)) From 93717394b22b4ad2bd222e478733de2ef5ef7d74 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Sun, 19 Jul 2026 21:41:45 +0800 Subject: [PATCH 06/23] =?UTF-8?q?fix:=20=E8=BF=81=E7=A7=BB=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20IP=20=E5=85=BC=E5=AE=B9=E5=BC=80=E5=85=B3?= 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/service/domain_constants.go | 1 + backend/internal/service/setting_parse.go | 5 +- backend/internal/service/setting_service.go | 36 +++- .../service/setting_service_update_test.go | 164 ++++++++++++++++++ 4 files changed, 196 insertions(+), 10 deletions(-) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index cbbb4b7850..c99d751c87 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -165,6 +165,7 @@ const ( // API Key IP 访问控制设置 SettingKeyAPIKeyACLTrustForwardedIP = "api_key_acl_trust_forwarded_ip" // API Key IP 白/黑名单是否信任转发 IP + settingKeyForwardedClientIPModeV2 = "forwarded_client_ip_mode_v2_migrated" // TOTP 双因素认证设置 SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能 diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index a56e57784e..4481bf262d 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -54,7 +54,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyLoginAgreementMode: defaultLoginAgreementMode, SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, - SettingKeyAPIKeyACLTrustForwardedIP: "false", + SettingKeyAPIKeyACLTrustForwardedIP: "true", + settingKeyForwardedClientIPModeV2: "true", SettingKeySiteName: "Sub2API", SettingKeySiteLogo: "", SettingKeyPurchaseSubscriptionEnabled: "false", @@ -249,7 +250,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin if value, ok := settings[SettingKeyAPIKeyACLTrustForwardedIP]; ok { apiKeyACLTrustForwardedIP = value == "true" } else if s != nil && s.cfg != nil { - apiKeyACLTrustForwardedIP = s.cfg.Security.TrustForwardedIPForAPIKeyACL + apiKeyACLTrustForwardedIP = s.cfg.ForwardedClientIPTrustEnabled() } result := &SystemSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 52acc62cf9..608cc0320b 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -2,7 +2,6 @@ package service import ( "context" - "errors" "fmt" "sync/atomic" @@ -220,15 +219,36 @@ func (s *SettingService) LoadAPIKeyACLTrustForwardedIPSetting(ctx context.Contex if s == nil || s.cfg == nil || s.settingRepo == nil { return nil } - value, err := s.settingRepo.GetValue(ctx, SettingKeyAPIKeyACLTrustForwardedIP) + + values, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyAPIKeyACLTrustForwardedIP, + settingKeyForwardedClientIPModeV2, + }) if err != nil { - if errors.Is(err, ErrSettingNotFound) { - s.cfg.SetTrustForwardedIPForAPIKeyACL(s.cfg.Security.TrustForwardedIPForAPIKeyACL) - return nil - } - return fmt.Errorf("get api key acl forwarded ip setting: %w", err) + s.cfg.SetTrustForwardedIPForAPIKeyACL(false) + return fmt.Errorf("get forwarded client ip settings: %w", err) } - enabled := value == "true" + + enabled := s.cfg.Security.TrustForwardedIPForAPIKeyACL + storedValue, hasStoredValue := values[SettingKeyAPIKeyACLTrustForwardedIP] + if hasStoredValue { + enabled = storedValue == "true" + } + + if values[settingKeyForwardedClientIPModeV2] != "true" { + updates := map[string]string{settingKeyForwardedClientIPModeV2: "true"} + // Before this migration, new installations persisted false by default. + // Restore compatibility only when no trusted-proxy policy was configured. + if hasStoredValue && !enabled && !s.cfg.Server.TrustedProxiesConfigured { + enabled = true + updates[SettingKeyAPIKeyACLTrustForwardedIP] = "true" + } + if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { + s.cfg.SetTrustForwardedIPForAPIKeyACL(enabled) + return fmt.Errorf("migrate forwarded client ip setting: %w", err) + } + } + s.cfg.SetTrustForwardedIPForAPIKeyACL(enabled) return nil } diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 93cf8284ad..efbf646feb 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -5,6 +5,7 @@ package service import ( "context" "encoding/json" + "errors" "math" "strconv" "testing" @@ -87,6 +88,58 @@ func (s *settingGetAllRepoStub) Delete(ctx context.Context, key string) error { panic("unexpected Delete call") } +type forwardedIPMigrationRepoStub struct { + values map[string]string + updates map[string]string + getMultipleErr error + setMultipleErr error +} + +func (s *forwardedIPMigrationRepoStub) Get(context.Context, string) (*Setting, error) { + panic("unexpected Get call") +} + +func (s *forwardedIPMigrationRepoStub) GetValue(context.Context, string) (string, error) { + panic("unexpected GetValue call") +} + +func (s *forwardedIPMigrationRepoStub) Set(context.Context, string, string) error { + panic("unexpected Set call") +} + +func (s *forwardedIPMigrationRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + if s.getMultipleErr != nil { + return nil, s.getMultipleErr + } + result := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := s.values[key]; ok { + result[key] = value + } + } + return result, nil +} + +func (s *forwardedIPMigrationRepoStub) SetMultiple(_ context.Context, values map[string]string) error { + if s.setMultipleErr != nil { + return s.setMultipleErr + } + s.updates = make(map[string]string, len(values)) + for key, value := range values { + s.values[key] = value + s.updates[key] = value + } + return nil +} + +func (s *forwardedIPMigrationRepoStub) GetAll(context.Context) (map[string]string, error) { + panic("unexpected GetAll call") +} + +func (s *forwardedIPMigrationRepoStub) Delete(context.Context, string) error { + panic("unexpected Delete call") +} + type settingAntigravityUARepoStub struct { values map[string]string } @@ -519,6 +572,117 @@ func TestSettingService_ParseSettings_APIKeyACLTrustForwardedIPFallsBackToConfig require.True(t, got.APIKeyACLTrustForwardedIP) } +func TestSettingService_ParseSettings_APIKeyACLTrustForwardedIPUsesStoredValue(t *testing.T) { + cfg := &config.Config{} + cfg.SetTrustForwardedIPForAPIKeyACL(true) + svc := NewSettingService(&settingUpdateRepoStub{}, cfg) + + got := svc.parseSettings(map[string]string{SettingKeyAPIKeyACLTrustForwardedIP: "false"}) + + require.False(t, got.APIKeyACLTrustForwardedIP) +} + +func TestSettingService_LoadAPIKeyACLTrustForwardedIPSettingMigration(t *testing.T) { + tests := []struct { + name string + values map[string]string + trustedProxiesSet bool + configDefault bool + wantEnabled bool + wantForwardedIPUpdate string + wantMigrationMarkerSet bool + }{ + { + name: "missing setting follows configured default", + values: map[string]string{}, + configDefault: true, + wantEnabled: true, + wantMigrationMarkerSet: true, + }, + { + name: "legacy false without proxy config migrates to compatibility", + values: map[string]string{SettingKeyAPIKeyACLTrustForwardedIP: "false"}, + wantEnabled: true, + wantForwardedIPUpdate: "true", + wantMigrationMarkerSet: true, + }, + { + name: "legacy false with explicit proxy config stays secure", + values: map[string]string{SettingKeyAPIKeyACLTrustForwardedIP: "false"}, + trustedProxiesSet: true, + wantEnabled: false, + wantMigrationMarkerSet: true, + }, + { + name: "completed migration preserves later false choice", + values: map[string]string{ + SettingKeyAPIKeyACLTrustForwardedIP: "false", + settingKeyForwardedClientIPModeV2: "true", + }, + wantEnabled: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repo := &forwardedIPMigrationRepoStub{values: test.values} + cfg := &config.Config{Server: config.ServerConfig{TrustedProxiesConfigured: test.trustedProxiesSet}} + cfg.Security.TrustForwardedIPForAPIKeyACL = test.configDefault + svc := NewSettingService(repo, cfg) + + require.NoError(t, svc.LoadAPIKeyACLTrustForwardedIPSetting(context.Background())) + require.Equal(t, test.wantEnabled, cfg.TrustForwardedIPForAPIKeyACL()) + require.Equal(t, test.wantForwardedIPUpdate, repo.updates[SettingKeyAPIKeyACLTrustForwardedIP]) + if test.wantMigrationMarkerSet { + require.Equal(t, "true", repo.updates[settingKeyForwardedClientIPModeV2]) + } else { + require.Nil(t, repo.updates) + } + }) + } +} + +func TestSettingService_LoadAPIKeyACLTrustForwardedIPSettingReadFailureFailsClosed(t *testing.T) { + repo := &forwardedIPMigrationRepoStub{ + getMultipleErr: errors.New("database unavailable"), + } + cfg := &config.Config{} + cfg.SetTrustForwardedIPForAPIKeyACL(true) + svc := NewSettingService(repo, cfg) + + err := svc.LoadAPIKeyACLTrustForwardedIPSetting(context.Background()) + + require.ErrorContains(t, err, "get forwarded client ip settings") + require.False(t, cfg.TrustForwardedIPForAPIKeyACL()) +} + +func TestSettingService_LoadAPIKeyACLTrustForwardedIPSettingWriteFailureUsesComputedMode(t *testing.T) { + tests := []struct { + name string + trustedProxiesSet bool + wantEnabled bool + }{ + {name: "compatibility migration remains effective", wantEnabled: true}, + {name: "explicit proxy policy remains secure", trustedProxiesSet: true, wantEnabled: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repo := &forwardedIPMigrationRepoStub{ + values: map[string]string{SettingKeyAPIKeyACLTrustForwardedIP: "false"}, + setMultipleErr: errors.New("database unavailable"), + } + cfg := &config.Config{Server: config.ServerConfig{TrustedProxiesConfigured: test.trustedProxiesSet}} + svc := NewSettingService(repo, cfg) + + err := svc.LoadAPIKeyACLTrustForwardedIPSetting(context.Background()) + + require.ErrorContains(t, err, "migrate forwarded client ip setting") + require.Equal(t, test.wantEnabled, cfg.TrustForwardedIPForAPIKeyACL()) + }) + } +} + func TestSettingService_GetAntigravityUserAgentVersion_Precedence(t *testing.T) { t.Run("后台设置优先", func(t *testing.T) { svc := NewSettingService(&settingAntigravityUARepoStub{values: map[string]string{ From 5da3325a306dd45afe5bd7178e03fa829310ca3d Mon Sep 17 00:00:00 2001 From: Jlypx Date: Sun, 19 Jul 2026 21:42:12 +0800 Subject: [PATCH 07/23] =?UTF-8?q?feat:=20=E5=9C=A8=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=AD=E9=85=8D=E7=BD=AE=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=20IP=20=E6=A8=A1=E5=BC=8F?= 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 --- .../src/i18n/locales/en/admin/settings.ts | 2 +- .../src/i18n/locales/zh/admin/settings.ts | 2 +- frontend/src/views/admin/SettingsView.vue | 2 +- .../admin/__tests__/SettingsView.spec.ts | 29 +++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 531769eec1..c08ca2ae52 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -153,7 +153,7 @@ export default { 'Choose which client IP is used by API Key allowlists/denylists, admin audit logs, and session IP/UA binding', trustForwardedIp: 'Trust forwarded client IP', trustForwardedIpHint: - 'Disabled by default. Enable only when the origin is reachable only through Cloudflare or Nginx reverse proxy. When enabled, API Key IP allowlists/denylists, admin audit logs, and session IP/UA binding use CF-Connecting-IP, X-Real-IP, or X-Forwarded-For, matching the request IP shown in usage records. Toggling this switch changes the IP fingerprint of existing sessions; with session binding enabled they must sign in again.' + 'Enabled by default for upgrade compatibility. When enabled, raw CF-Connecting-IP, X-Real-IP, or X-Forwarded-For values take over server.trusted_proxies for client-IP resolution. Disable it to enforce the Gin trusted-proxy chain configured by server.trusted_proxies. Only enable takeover mode when the origin cannot be reached directly. Changing this switch changes existing session IP fingerprints.' }, linuxdo: { title: 'LinuxDo Connect Login', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index f656ac4886..bfd3f139d5 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -152,7 +152,7 @@ export default { description: '控制 API Key 白/黑名单、操作审计日志与会话 IP/UA 绑定使用哪个客户端 IP 判断', trustForwardedIp: '信任反代传递的客户端 IP', trustForwardedIpHint: - '默认关闭。仅在源站只允许 Cloudflare 或 Nginx 反代访问时开启;开启后 API Key IP 白/黑名单、操作审计日志与会话 IP/UA 绑定会使用 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For,与使用记录中的请求 IP 保持一致。切换本开关会改变已登录会话的 IP 指纹,开启会话绑定时现有会话需重新登录。' + '为保证升级兼容默认开启。开启后 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For 会直接接管客户端 IP 解析并覆盖 server.trusted_proxies;关闭后严格使用 server.trusted_proxies 配置的 Gin 可信代理链。仅在源站无法被直接访问时开启接管模式。切换会改变现有会话的 IP 指纹。' }, linuxdo: { title: 'LinuxDo Connect 登录', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index b2b1c760c8..c5c898ee85 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -8393,7 +8393,7 @@ const form = reactive({ turnstile_site_key: "", turnstile_secret_key: "", turnstile_secret_key_configured: false, - api_key_acl_trust_forwarded_ip: false, + api_key_acl_trust_forwarded_ip: true, // LinuxDo Connect OAuth 登录 linuxdo_connect_enabled: false, linuxdo_connect_client_id: "", diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 8f73963362..8b7ffb301c 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -367,6 +367,7 @@ const baseSettingsResponse = { turnstile_enabled: false, turnstile_site_key: "", turnstile_secret_key_configured: false, + api_key_acl_trust_forwarded_ip: true, linuxdo_connect_enabled: false, linuxdo_connect_client_id: "", linuxdo_connect_client_secret_configured: false, @@ -652,6 +653,34 @@ describe("admin SettingsView payment visible method controls", () => { expect(wrapper.text()).not.toContain("支付来源"); }); + it("loads and saves the forwarded client IP takeover switch", async () => { + getSettings.mockResolvedValueOnce({ + ...baseSettingsResponse, + api_key_acl_trust_forwarded_ip: false, + }); + const wrapper = mountView(); + + await flushPromises(); + await openSecurityTab(wrapper); + + const card = wrapper + .findAll(".card") + .find((node) => node.text().includes("admin.settings.apiKeyAcl.title")); + expect(card).toBeDefined(); + const toggle = card!.get('input[type="checkbox"]'); + expect((toggle.element as HTMLInputElement).checked).toBe(false); + + await toggle.setValue(true); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + api_key_acl_trust_forwarded_ip: true, + }), + ); + }); + it("links payment guidance to README sections instead of removed payment docs", async () => { const wrapper = mountView(); From 41b58b640a607a6593ee1bafce782c3f6de4e09a Mon Sep 17 00:00:00 2001 From: Jlypx Date: Sun, 19 Jul 2026 21:42:33 +0800 Subject: [PATCH 08/23] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=8F=AF?= =?UTF-8?q?=E4=BF=A1=E4=BB=A3=E7=90=86=E9=83=A8=E7=BD=B2=E8=AF=B4=E6=98=8E?= 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 --- deploy/EDGE_SECURITY.md | 28 +++++++++++++++------------- deploy/config.example.yaml | 22 ++++++++++++---------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/deploy/EDGE_SECURITY.md b/deploy/EDGE_SECURITY.md index b2c08cf20c..0c0e78768a 100644 --- a/deploy/EDGE_SECURITY.md +++ b/deploy/EDGE_SECURITY.md @@ -29,21 +29,23 @@ the application's responsibility. ## Trusted client IPs -`server.trusted_proxies` controls forwarded-IP trust for security-sensitive -paths such as API-key ACLs, session binding, and rejection aggregation. Fresh -installations default to local/container ranges (`127.0.0.0/8`, `::1/128`, -`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, and `fc00::/7`) so a local -Nginx/Caddy or Docker bridge works without a migration. For a remote load -balancer, replace the defaults with only the CIDRs that connect directly to -Sub2API. An explicit empty list disables forwarded-IP trust for these paths; -ordinary request/usage metadata keeps its legacy compatibility behavior. +`security.trust_forwarded_ip_for_api_key_acl` is enabled by default for upgrade +compatibility. On the first upgrade to this mode, a legacy `false` value is +changed to `true` only when `server.trusted_proxies` was not explicitly +configured; explicit proxy policies remain in secure mode. Later administrator +changes are preserved. While enabled, raw `CF-Connecting-IP`, `X-Real-IP`, and +`X-Forwarded-For` values take over client-IP resolution for logs and +security-sensitive paths. Disable the switch to make Gin's +`server.trusted_proxies` chain authoritative. Configure only the exact CIDR/IP +addresses that connect directly to Sub2API; an explicit empty list trusts no +forwarded client IPs while the switch is disabled. -Never use `CF-Connecting-IP`, `X-Real-IP`, or `X-Forwarded-For` for an ACL or -session decision merely because the header exists. A CDN deployment must -firewall the origin so only the CDN or load balancer can reach it, and the proxy -must overwrite forwarded headers. +Compatibility takeover accepts forwarded headers without validating the direct +peer. Protect the origin from direct access while it is enabled. A CDN +deployment must firewall the origin so only the CDN or load balancer can reach +it, and the proxy must overwrite forwarded headers. -Example for a proxy on the same host (the default already covers this case): +Example for a proxy on the same host: ```yaml server: diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 2720c2d298..e84a906d4d 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -36,18 +36,15 @@ server: # Keep-alive idle timeout in seconds. # Keep-Alive 空闲连接超时(秒)。 idle_timeout: 120 - # Trusted proxies for security-sensitive X-Forwarded-For parsing (CIDR/IP). - # These local/container ranges are the default; replace them with the exact - # proxy CIDRs for a remote load balancer. Set [] explicitly to disable trust. - # 安全敏感场景解析 X-Forwarded-For 的可信代理(CIDR/IP)。以下为本机/容器 - # 网段默认值;远程负载均衡请替换为实际 CIDR。显式设置 [] 可禁用代理信任。 + # Trusted proxies used when security.trust_forwarded_ip_for_api_key_acl is false. + # List only the exact proxy addresses that connect directly to Sub2API. + # Set [] explicitly to disable forwarded-IP trust in high-security mode. + # security.trust_forwarded_ip_for_api_key_acl=false 时使用的可信代理。 + # 只填写直接连接 Sub2API 的精确代理地址;显式设置 [] 可在高安全模式下 + # 禁用转发 IP 信任。 trusted_proxies: - - 127.0.0.0/8 + - 127.0.0.1/32 - ::1/128 - - 10.0.0.0/8 - - 172.16.0.0/12 - - 192.168.0.0/16 - - fc00::/7 # Global max request body size in bytes (default: 256MB) # 全局最大请求体大小(字节,默认 256MB) # Applies to all requests, especially important for h2c first request memory protection @@ -104,6 +101,11 @@ cors: # 安全配置 # ============================================================================= security: + # Legacy compatibility switch. When true, raw forwarded headers take over + # server.trusted_proxies. Set false to enforce the trusted proxy chain above. + # 旧版兼容开关。开启时原始转发头会接管 server.trusted_proxies;关闭后严格 + # 使用上方配置的可信代理链。示例配置采用高安全模式。 + trust_forwarded_ip_for_api_key_acl: false url_allowlist: # Enable URL allowlist validation (disable to skip all URL checks) # 启用 URL 白名单验证(禁用则跳过所有 URL 检查) From f69042ca6e5541f2662311b451a6aca44d0a3f27 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Sun, 19 Jul 2026 21:42:54 +0800 Subject: [PATCH 09/23] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20IP=20=E9=85=8D=E7=BD=AE=E7=B4=A2=E5=BC=95?= 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 --- README.md | 2 +- README_CN.md | 2 +- README_JA.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1e34840b00..16e7bd9a5d 100644 --- a/README.md +++ b/README.md @@ -571,7 +571,7 @@ Additional security-related options are available in `config.yaml`: - `security.response_headers.enabled` to enable configurable response header filtering (disabled uses default allowlist) - `security.csp` to control Content-Security-Policy headers - `billing.circuit_breaker` to fail closed on billing errors -- `server.trusted_proxies` to configure forwarded-IP trust for security-sensitive paths (local/container proxy ranges are trusted by default; replace them with exact remote proxy CIDRs when needed) +- `security.trust_forwarded_ip_for_api_key_acl` enables legacy raw forwarded-header takeover (enabled by default for upgrade compatibility); disable it to enforce `server.trusted_proxies`, which should contain only the exact proxy CIDRs that connect directly to Sub2API - `turnstile.required` to require Turnstile in release mode **⚠️ Security Warning: HTTP URL Configuration** diff --git a/README_CN.md b/README_CN.md index 632b6532db..a9aea3bbe8 100644 --- a/README_CN.md +++ b/README_CN.md @@ -607,7 +607,7 @@ gateway: - `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单) - `security.csp` 配置 Content-Security-Policy - `billing.circuit_breaker` 计费异常时 fail-closed -- `server.trusted_proxies` 配置安全敏感路径的反代 IP 信任(本机/常见 Docker 私网网段默认已信任;远程反代请替换为精确 CIDR) +- `security.trust_forwarded_ip_for_api_key_acl` 控制旧版原始转发头接管(为升级兼容默认开启);关闭后严格使用 `server.trusted_proxies`,其中只应填写直接连接 Sub2API 的精确代理 CIDR - `turnstile.required` 在 release 模式强制启用 Turnstile **网关防御纵深建议(重点)** diff --git a/README_JA.md b/README_JA.md index 849d951f68..8d95162612 100644 --- a/README_JA.md +++ b/README_JA.md @@ -569,7 +569,7 @@ default: - `security.response_headers.enabled` - 設定可能なレスポンスヘッダーフィルタリングを有効化(無効時はデフォルトの許可リストを使用) - `security.csp` - Content-Security-Policy ヘッダーの制御 - `billing.circuit_breaker` - 課金エラー時にフェイルクローズ -- `server.trusted_proxies` - セキュリティ用途の転送 IP 信頼を設定(ローカル/一般的な Docker プライベート範囲はデフォルトで信頼。リモートプロキシは正確な CIDR に置換) +- `security.trust_forwarded_ip_for_api_key_acl` - 従来の生転送ヘッダーによる上書きを制御(アップグレード互換性のため既定で有効)。無効にすると `server.trusted_proxies` を厳格に使用し、Sub2API に直接接続するプロキシの正確な CIDR のみを指定 - `turnstile.required` - リリースモードでの Turnstile 必須化 **⚠️ セキュリティ警告: HTTP URL 設定** From 041db5d8245cb898b99142b1af737c77a3eae679 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:08:26 +0800 Subject: [PATCH 10/23] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=AE=A2=E6=88=B7=E7=AB=AF=20IP=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=A4=B4=E9=85=8D=E7=BD=AE?= 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/config/config.go | 102 +++++++++++++++++---- backend/internal/config/config_test.go | 122 +++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 16 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6aef080721..c60ef2a461 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "math" + "net/textproto" "net/url" "os" "strings" @@ -14,6 +15,7 @@ import ( "time" "github.com/spf13/viper" + "golang.org/x/net/http/httpguts" ) const ( @@ -668,6 +670,13 @@ type CORSConfig struct { AllowCredentials bool `mapstructure:"allow_credentials"` } +const MaxForwardedClientIPHeaders = 16 + +type ForwardedClientIPSettings struct { + TrustForwardedIP bool + Headers []string +} + type SecurityConfig struct { URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"` ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"` @@ -676,19 +685,58 @@ type SecurityConfig struct { ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"` // TrustForwardedIPForAPIKeyACL enables legacy raw forwarded-header takeover. // When disabled, server.trusted_proxies is authoritative for all client-IP consumers. - TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"` - trustForwardedIPForAPIKeyACLLive *atomic.Bool `mapstructure:"-"` + TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"` + ForwardedClientIPHeaders []string `mapstructure:"forwarded_client_ip_headers" json:"forwarded_client_ip_headers" yaml:"forwarded_client_ip_headers"` + forwardedClientIPSettingsLive atomic.Pointer[ForwardedClientIPSettings] `mapstructure:"-" json:"-" yaml:"-"` +} + +func NormalizeForwardedClientIPHeaders(headers []string) ([]string, error) { + normalized := make([]string, 0, len(headers)) + seen := make(map[string]struct{}, len(headers)) + for _, header := range headers { + header = strings.TrimSpace(header) + if !httpguts.ValidHeaderFieldName(header) { + return nil, fmt.Errorf("invalid HTTP header field name %q", header) + } + canonical := textproto.CanonicalMIMEHeaderKey(header) + key := strings.ToLower(canonical) + if _, exists := seen[key]; exists { + continue + } + if len(normalized) == MaxForwardedClientIPHeaders { + return nil, fmt.Errorf("forwarded client IP headers must contain at most %d unique names", MaxForwardedClientIPHeaders) + } + seen[key] = struct{}{} + normalized = append(normalized, canonical) + } + return normalized, nil +} + +func cloneForwardedClientIPHeaders(headers []string) []string { + if len(headers) == 0 { + return []string{} + } + return append([]string(nil), headers...) +} + +func (c *Config) ForwardedClientIPSettings() ForwardedClientIPSettings { + if c == nil { + return ForwardedClientIPSettings{Headers: []string{}} + } + if snapshot := c.Security.forwardedClientIPSettingsLive.Load(); snapshot != nil { + return ForwardedClientIPSettings{ + TrustForwardedIP: snapshot.TrustForwardedIP, + Headers: cloneForwardedClientIPHeaders(snapshot.Headers), + } + } + return ForwardedClientIPSettings{ + TrustForwardedIP: c.Security.TrustForwardedIPForAPIKeyACL, + Headers: cloneForwardedClientIPHeaders(c.Security.ForwardedClientIPHeaders), + } } func (c *Config) TrustForwardedIPForAPIKeyACL() bool { - if c == nil { - return false - } - live := c.Security.trustForwardedIPForAPIKeyACLLive - if live == nil { - return c.Security.TrustForwardedIPForAPIKeyACL - } - return live.Load() + return c.ForwardedClientIPSettings().TrustForwardedIP } // ForwardedClientIPTrustEnabled reports whether the legacy forwarded-header @@ -697,15 +745,22 @@ func (c *Config) ForwardedClientIPTrustEnabled() bool { return c != nil && c.TrustForwardedIPForAPIKeyACL() } +func (c *Config) SetForwardedClientIPSettings(enabled bool, headers []string) { + if c == nil { + return + } + headers = cloneForwardedClientIPHeaders(headers) + c.Security.forwardedClientIPSettingsLive.Store(&ForwardedClientIPSettings{ + TrustForwardedIP: enabled, + Headers: headers, + }) +} + func (c *Config) SetTrustForwardedIPForAPIKeyACL(enabled bool) { if c == nil { return } - c.Security.TrustForwardedIPForAPIKeyACL = enabled - if c.Security.trustForwardedIPForAPIKeyACLLive == nil { - c.Security.trustForwardedIPForAPIKeyACLLive = &atomic.Bool{} - } - c.Security.trustForwardedIPForAPIKeyACLLive.Store(enabled) + c.SetForwardedClientIPSettings(enabled, c.ForwardedClientIPSettings().Headers) } type URLAllowlistConfig struct { @@ -1574,6 +1629,7 @@ func load(allowMissingJWTSecret bool) (*Config, error) { // 配置文件不存在时使用默认值 } trustedProxiesEnv, trustedProxiesEnvConfigured := os.LookupEnv("SERVER_TRUSTED_PROXIES") + forwardedClientIPHeadersEnv, forwardedClientIPHeadersEnvConfigured := os.LookupEnv("SECURITY_FORWARDED_CLIENT_IP_HEADERS") trustedProxiesConfigured := viper.InConfig("server.trusted_proxies") || viper.IsSet("server.trusted_proxies") || trustedProxiesEnvConfigured @@ -1584,6 +1640,9 @@ func load(allowMissingJWTSecret bool) (*Config, error) { if trustedProxiesEnvConfigured { cfg.Server.TrustedProxies = normalizeStringSlice(strings.Split(trustedProxiesEnv, ",")) } + if forwardedClientIPHeadersEnvConfigured { + cfg.Security.ForwardedClientIPHeaders = normalizeStringSlice(strings.Split(forwardedClientIPHeadersEnv, ",")) + } cfg.Server.TrustedProxiesConfigured = trustedProxiesConfigured if cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs == 0 { cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000 @@ -1640,7 +1699,12 @@ func load(allowMissingJWTSecret bool) (*Config, error) { cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed) cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove) cfg.Security.CSP.Policy = strings.TrimSpace(cfg.Security.CSP.Policy) - cfg.SetTrustForwardedIPForAPIKeyACL(cfg.Security.TrustForwardedIPForAPIKeyACL) + forwardedClientIPHeaders, err := NormalizeForwardedClientIPHeaders(cfg.Security.ForwardedClientIPHeaders) + if err != nil { + return nil, fmt.Errorf("security.forwarded_client_ip_headers: %w", err) + } + cfg.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders + cfg.SetForwardedClientIPSettings(cfg.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders) cfg.Log.Level = strings.ToLower(strings.TrimSpace(cfg.Log.Level)) cfg.Log.Format = strings.ToLower(strings.TrimSpace(cfg.Log.Format)) cfg.Log.ServiceName = strings.TrimSpace(cfg.Log.ServiceName) @@ -2244,6 +2308,12 @@ func setDefaults() { } func (c *Config) Validate() error { + forwardedClientIPHeaders, err := NormalizeForwardedClientIPHeaders(c.Security.ForwardedClientIPHeaders) + if err != nil { + return fmt.Errorf("security.forwarded_client_ip_headers: %w", err) + } + c.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders + c.SetForwardedClientIPSettings(c.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders) if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 { return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds") } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 20da10a6a7..dece5fd5ae 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,10 +1,12 @@ package config import ( + "fmt" "math" "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -50,6 +52,126 @@ func TestLoadHTTPIngressSafetyDefaults(t *testing.T) { require.Equal(t, 16384, cfg.APIKeyAuth.InvalidAbuse.Capacity) } +func TestNormalizeForwardedClientIPHeaders(t *testing.T) { + headers, err := NormalizeForwardedClientIPHeaders([]string{ + " x-cdn-client-ip ", + "X-CDN-CLIENT-IP", + "true-client-ip", + }) + require.NoError(t, err) + require.Equal(t, []string{"X-Cdn-Client-Ip", "True-Client-Ip"}, headers) + + _, err = NormalizeForwardedClientIPHeaders([]string{"X Invalid"}) + require.ErrorContains(t, err, "invalid HTTP header field name") +} + +func TestNormalizeForwardedClientIPHeadersLimit(t *testing.T) { + headers := make([]string, 0, MaxForwardedClientIPHeaders+1) + for i := 0; i <= MaxForwardedClientIPHeaders; i++ { + headers = append(headers, fmt.Sprintf("X-CDN-IP-%d", i)) + } + + _, err := NormalizeForwardedClientIPHeaders(headers) + require.ErrorContains(t, err, "at most 16 unique names") +} + +func TestLoadForwardedClientIPHeadersNormalizesAndSnapshots(t *testing.T) { + resetViperWithJWTSecret(t) + viper.Set("security.forwarded_client_ip_headers", []string{" x-cdn-ip ", "X-CDN-IP", "true-client-ip"}) + + cfg, err := Load() + require.NoError(t, err) + snapshot := cfg.ForwardedClientIPSettings() + require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, snapshot.Headers) + + snapshot.Headers[0] = "X-Mutated" + require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, cfg.ForwardedClientIPSettings().Headers) +} + +func TestForwardedClientIPSettingsConcurrentPublication(t *testing.T) { + cfg := &Config{} + cfg.SetForwardedClientIPSettings(true, []string{"X-Public-A"}) + + const iterations = 2000 + start := make(chan struct{}) + errCh := make(chan error, 8) + var wg sync.WaitGroup + + for _, settings := range []ForwardedClientIPSettings{ + {TrustForwardedIP: true, Headers: []string{"X-Public-A"}}, + {TrustForwardedIP: false, Headers: []string{"X-Public-B"}}, + } { + settings := settings + wg.Add(1) + go func() { + defer wg.Done() + <-start + for i := 0; i < iterations; i++ { + cfg.SetForwardedClientIPSettings(settings.TrustForwardedIP, settings.Headers) + } + }() + } + + for i := 0; i < cap(errCh); i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + for j := 0; j < iterations; j++ { + snapshot := cfg.ForwardedClientIPSettings() + validA := snapshot.TrustForwardedIP && len(snapshot.Headers) == 1 && snapshot.Headers[0] == "X-Public-A" + validB := !snapshot.TrustForwardedIP && len(snapshot.Headers) == 1 && snapshot.Headers[0] == "X-Public-B" + if !validA && !validB { + errCh <- fmt.Errorf("observed inconsistent forwarded IP settings: %+v", snapshot) + return + } + } + }() + } + + close(start) + wg.Wait() + close(errCh) + for err := range errCh { + require.NoError(t, err) + } +} + +func TestLoadForwardedClientIPHeadersFromEnvironment(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", " x-cdn-ip , X-CDN-IP, true-client-ip ") + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, cfg.ForwardedClientIPSettings().Headers) +} + +func TestLoadExplicitEmptyForwardedClientIPHeadersFromEnvironment(t *testing.T) { + resetViperWithJWTSecret(t) + viper.Set("security.forwarded_client_ip_headers", []string{"X-Yaml-IP"}) + t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", "") + + cfg, err := Load() + require.NoError(t, err) + require.Empty(t, cfg.ForwardedClientIPSettings().Headers) +} + +func TestLoadRejectsInvalidForwardedClientIPHeaderFromEnvironment(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("SECURITY_FORWARDED_CLIENT_IP_HEADERS", "X-Valid-IP, X Invalid") + + _, err := Load() + require.ErrorContains(t, err, "security.forwarded_client_ip_headers") +} + +func TestLoadRejectsInvalidForwardedClientIPHeader(t *testing.T) { + resetViperWithJWTSecret(t) + viper.Set("security.forwarded_client_ip_headers", []string{"X Invalid"}) + + _, err := Load() + require.ErrorContains(t, err, "security.forwarded_client_ip_headers") +} + func TestLoadExplicitEmptyTrustedProxiesEnablesConfiguredMode(t *testing.T) { resetViperWithJWTSecret(t) viper.Set("server.trusted_proxies", []string{}) From 496005d68812e442cf641b7728fff58133d12607 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:08:36 +0800 Subject: [PATCH 11/23] =?UTF-8?q?fix:=20=E6=8C=89=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E8=AF=B7=E6=B1=82=E5=A4=B4=E8=A7=A3=E6=9E=90=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=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) From 9bc7d10c086e4b3bbba1e72ae092ef506c23d388 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:08:48 +0800 Subject: [PATCH 12/23] =?UTF-8?q?fix:=20=E5=BF=AB=E7=85=A7=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=AE=A2=E6=88=B7=E7=AB=AF=20IP=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=A4=B4?= 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 --- .../server/middleware/session_binding.go | 6 ++-- .../server/middleware/session_binding_test.go | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/backend/internal/server/middleware/session_binding.go b/backend/internal/server/middleware/session_binding.go index 3f4d04b935..19fca22032 100644 --- a/backend/internal/server/middleware/session_binding.go +++ b/backend/internal/server/middleware/session_binding.go @@ -17,12 +17,12 @@ import ( // 接管解析,关闭时使用 Gin 的 server.trusted_proxies 可信代理链。 func SessionBindingContext(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { - trustForwarded := cfg.TrustForwardedIPForAPIKeyACL() - ip.SetLegacyForwardedIPTrust(c, trustForwarded) + forwardedIPSettings := cfg.ForwardedClientIPSettings() + ip.SetForwardedIPSettings(c, forwardedIPSettings.TrustForwardedIP, forwardedIPSettings.Headers) userAgent := normalizePersistentText(c.Request.UserAgent(), maxPersistentUserAgentBytes) c.Request.Header.Set("User-Agent", userAgent) binding := &service.SessionBinding{ - IP: ip.GetSecurityClientIP(c, trustForwarded), + IP: ip.GetSecurityClientIP(c, forwardedIPSettings.TrustForwardedIP), UserAgent: userAgent, } c.Request = c.Request.WithContext(service.WithSessionBinding(c.Request.Context(), binding)) diff --git a/backend/internal/server/middleware/session_binding_test.go b/backend/internal/server/middleware/session_binding_test.go index 11a098d9fd..08e9448788 100644 --- a/backend/internal/server/middleware/session_binding_test.go +++ b/backend/internal/server/middleware/session_binding_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -55,6 +56,39 @@ func TestSessionBindingContextFollowsForwardedIPSwitch(t *testing.T) { } } +func TestSessionBindingContextSnapshotsForwardedModeAndHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := &config.Config{} + cfg.SetForwardedClientIPSettings(true, []string{"X-Initial-IP"}) + + r := gin.New() + require.NoError(t, r.SetTrustedProxies(nil)) + r.Use(SessionBindingContext(cfg)) + r.GET("/t", func(c *gin.Context) { + binding := service.SessionBindingFromContext(c.Request.Context()) + require.NotNil(t, binding) + require.Equal(t, "1.2.3.4", binding.IP) + + cfg.SetForwardedClientIPSettings(false, []string{"X-Changed-IP"}) + require.Equal(t, "1.2.3.4", ip.GetSecurityClientIP(c, false)) + c.Status(200) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/t", nil) + req.RemoteAddr = "9.9.9.9:12345" + req.Header.Set("X-Initial-IP", "1.2.3.4") + req.Header.Set("X-Changed-IP", "4.4.4.4") + req.Header.Set("X-Real-IP", "8.8.8.8") + r.ServeHTTP(w, req) + + require.Equal(t, 200, w.Code) + runtimeSettings := cfg.ForwardedClientIPSettings() + require.False(t, runtimeSettings.TrustForwardedIP) + require.Equal(t, []string{"X-Changed-IP"}, runtimeSettings.Headers) +} + func TestSessionBindingContextBoundsPersistedUserAgent(t *testing.T) { cfg := &config.Config{} r := gin.New() From 8b8b6b31326213e37860e82e8590e644e8c03dc3 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:09:00 +0800 Subject: [PATCH 13/23] =?UTF-8?q?feat:=20=E5=AE=9A=E4=B9=89=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20IP=20=E8=AF=B7=E6=B1=82=E5=A4=B4=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E8=AE=BE=E7=BD=AE?= 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/service/domain_constants.go | 1 + backend/internal/service/settings_view.go | 1 + 2 files changed, 2 insertions(+) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index c99d751c87..e503cfacab 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -165,6 +165,7 @@ const ( // API Key IP 访问控制设置 SettingKeyAPIKeyACLTrustForwardedIP = "api_key_acl_trust_forwarded_ip" // API Key IP 白/黑名单是否信任转发 IP + SettingKeyForwardedClientIPHeaders = "forwarded_client_ip_headers" // 自定义 CDN 客户端 IP 请求头(JSON 数组) settingKeyForwardedClientIPModeV2 = "forwarded_client_ip_mode_v2_migrated" // TOTP 双因素认证设置 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 85ab619d14..c775e2ee77 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -42,6 +42,7 @@ type SystemSettings struct { TurnstileSecretKey string TurnstileSecretKeyConfigured bool APIKeyACLTrustForwardedIP bool + ForwardedClientIPHeaders []string // LinuxDo Connect OAuth 登录 LinuxDoConnectEnabled bool From f72958d53093390c1b3369294b835bed8667ea10 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:09:11 +0800 Subject: [PATCH 14/23] =?UTF-8?q?feat:=20=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=20IP=20=E8=AF=B7=E6=B1=82=E5=A4=B4?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE?= 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/service/setting_parse.go | 43 ++++- backend/internal/service/setting_service.go | 56 ++++-- .../service/setting_service_update_test.go | 160 ++++++++++++++++-- backend/internal/service/setting_update.go | 13 +- backend/internal/service/wire.go | 4 +- 5 files changed, 242 insertions(+), 34 deletions(-) diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 4481bf262d..adc1195c04 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -43,6 +43,14 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { if err != nil { return err } + forwardedClientIPHeaders := []string{} + if s != nil && s.cfg != nil { + forwardedClientIPHeaders = s.cfg.ForwardedClientIPSettings().Headers + } + forwardedClientIPHeadersJSON, err := json.Marshal(forwardedClientIPHeaders) + if err != nil { + return fmt.Errorf("marshal default forwarded client IP headers: %w", err) + } // 初始化默认设置 defaults := map[string]string{ @@ -55,6 +63,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, SettingKeyAPIKeyACLTrustForwardedIP: "true", + SettingKeyForwardedClientIPHeaders: string(forwardedClientIPHeadersJSON), settingKeyForwardedClientIPModeV2: "true", SettingKeySiteName: "Sub2API", SettingKeySiteLogo: "", @@ -238,6 +247,21 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { return s.settingRepo.SetMultiple(ctx, defaults) } +func parseForwardedClientIPHeadersSetting(value string) ([]string, error) { + var headers []string + if err := json.Unmarshal([]byte(value), &headers); err != nil { + return nil, fmt.Errorf("parse forwarded_client_ip_headers: %w", err) + } + if headers == nil { + return nil, fmt.Errorf("parse forwarded_client_ip_headers: value must be a JSON array") + } + normalized, err := config.NormalizeForwardedClientIPHeaders(headers) + if err != nil { + return nil, fmt.Errorf("parse forwarded_client_ip_headers: %w", err) + } + return normalized, nil +} + // parseSettings 解析设置到结构体 func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" @@ -247,10 +271,24 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin loginAgreementUpdatedAt = defaultLoginAgreementDate } apiKeyACLTrustForwardedIP := false + forwardedClientIPHeaders := []string{} + if s != nil && s.cfg != nil { + runtimeSettings := s.cfg.ForwardedClientIPSettings() + apiKeyACLTrustForwardedIP = runtimeSettings.TrustForwardedIP + forwardedClientIPHeaders = runtimeSettings.Headers + } if value, ok := settings[SettingKeyAPIKeyACLTrustForwardedIP]; ok { apiKeyACLTrustForwardedIP = value == "true" - } else if s != nil && s.cfg != nil { - apiKeyACLTrustForwardedIP = s.cfg.ForwardedClientIPTrustEnabled() + } + if value, ok := settings[SettingKeyForwardedClientIPHeaders]; ok { + parsed, err := parseForwardedClientIPHeadersSetting(value) + if err != nil { + slog.Error("invalid persisted forwarded client IP headers; forwarded trust disabled", "error", err) + apiKeyACLTrustForwardedIP = false + forwardedClientIPHeaders = []string{} + } else { + forwardedClientIPHeaders = parsed + } } result := &SystemSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", @@ -278,6 +316,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, + ForwardedClientIPHeaders: forwardedClientIPHeaders, SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), SiteLogo: settings[SettingKeySiteLogo], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 608cc0320b..5f02110b46 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -2,6 +2,8 @@ package service import ( "context" + "encoding/json" + "errors" "fmt" "sync/atomic" @@ -215,42 +217,66 @@ func (s *SettingService) SetProxyRepository(repo ProxyRepository) { s.proxyRepo = repo } -func (s *SettingService) LoadAPIKeyACLTrustForwardedIPSetting(ctx context.Context) error { +func (s *SettingService) LoadForwardedClientIPSettings(ctx context.Context) error { if s == nil || s.cfg == nil || s.settingRepo == nil { return nil } values, err := s.settingRepo.GetMultiple(ctx, []string{ SettingKeyAPIKeyACLTrustForwardedIP, + SettingKeyForwardedClientIPHeaders, settingKeyForwardedClientIPModeV2, }) if err != nil { - s.cfg.SetTrustForwardedIPForAPIKeyACL(false) + s.cfg.SetForwardedClientIPSettings(false, nil) return fmt.Errorf("get forwarded client ip settings: %w", err) } enabled := s.cfg.Security.TrustForwardedIPForAPIKeyACL + headers := s.cfg.ForwardedClientIPSettings().Headers storedValue, hasStoredValue := values[SettingKeyAPIKeyACLTrustForwardedIP] if hasStoredValue { enabled = storedValue == "true" } - if values[settingKeyForwardedClientIPModeV2] != "true" { - updates := map[string]string{settingKeyForwardedClientIPModeV2: "true"} - // Before this migration, new installations persisted false by default. - // Restore compatibility only when no trusted-proxy policy was configured. - if hasStoredValue && !enabled && !s.cfg.Server.TrustedProxiesConfigured { - enabled = true - updates[SettingKeyAPIKeyACLTrustForwardedIP] = "true" - } - if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { - s.cfg.SetTrustForwardedIPForAPIKeyACL(enabled) - return fmt.Errorf("migrate forwarded client ip setting: %w", err) + var headersErr error + if storedHeaders, ok := values[SettingKeyForwardedClientIPHeaders]; ok { + headers, headersErr = parseForwardedClientIPHeadersSetting(storedHeaders) + if headersErr != nil { + enabled = false + headers = []string{} + headersErr = fmt.Errorf("load forwarded client ip headers: %w", headersErr) } } - s.cfg.SetTrustForwardedIPForAPIKeyACL(enabled) - return nil + updates := make(map[string]string) + if _, hasStoredHeaders := values[SettingKeyForwardedClientIPHeaders]; !hasStoredHeaders { + headersJSON, marshalErr := json.Marshal(headers) + if marshalErr != nil { + headers = []string{} + headersErr = errors.Join(headersErr, fmt.Errorf("marshal forwarded client ip headers: %w", marshalErr)) + headersJSON = []byte("[]") + } + updates[SettingKeyForwardedClientIPHeaders] = string(headersJSON) + } + if values[settingKeyForwardedClientIPModeV2] != "true" { + updates[settingKeyForwardedClientIPModeV2] = "true" + // Before this migration, new installations persisted false by default. + // Restore compatibility only when no trusted-proxy policy was configured. + if headersErr == nil && hasStoredValue && !enabled && !s.cfg.Server.TrustedProxiesConfigured { + enabled = true + updates[SettingKeyAPIKeyACLTrustForwardedIP] = "true" + } + } + if len(updates) > 0 { + if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { + s.cfg.SetForwardedClientIPSettings(enabled, headers) + return errors.Join(headersErr, fmt.Errorf("migrate forwarded client ip setting: %w", err)) + } + } + + s.cfg.SetForwardedClientIPSettings(enabled, headers) + return headersErr } // GetAllSettings 获取所有系统设置 diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index efbf646feb..3a4050f5b2 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -17,7 +17,8 @@ import ( ) type settingUpdateRepoStub struct { - updates map[string]string + updates map[string]string + setMultipleErr error } func (s *settingUpdateRepoStub) Get(ctx context.Context, key string) (*Setting, error) { @@ -41,7 +42,7 @@ func (s *settingUpdateRepoStub) SetMultiple(ctx context.Context, settings map[st for k, v := range settings { s.updates[k] = v } - return nil + return s.setMultipleErr } func (s *settingUpdateRepoStub) GetAll(ctx context.Context) (map[string]string, error) { @@ -99,8 +100,12 @@ func (s *forwardedIPMigrationRepoStub) Get(context.Context, string) (*Setting, e panic("unexpected Get call") } -func (s *forwardedIPMigrationRepoStub) GetValue(context.Context, string) (string, error) { - panic("unexpected GetValue call") +func (s *forwardedIPMigrationRepoStub) GetValue(_ context.Context, key string) (string, error) { + value, ok := s.values[key] + if !ok { + return "", ErrSettingNotFound + } + return value, nil } func (s *forwardedIPMigrationRepoStub) Set(context.Context, string, string) error { @@ -548,6 +553,16 @@ func TestSettingService_UpdateSettings_AntigravityUserAgentVersion(t *testing.T) require.Equal(t, "1.23.2", repo.updates[SettingKeyAntigravityUserAgentVersion]) } +func TestSettingService_InitializeDefaultSettingsPersistsConfiguredForwardedClientIPHeaders(t *testing.T) { + repo := &forwardedIPMigrationRepoStub{values: map[string]string{}} + cfg := &config.Config{} + cfg.SetForwardedClientIPSettings(true, []string{"X-Cdn-Ip", "True-Client-Ip"}) + svc := NewSettingService(repo, cfg) + + require.NoError(t, svc.InitializeDefaultSettings(context.Background())) + require.JSONEq(t, `["X-Cdn-Ip","True-Client-Ip"]`, repo.values[SettingKeyForwardedClientIPHeaders]) +} + func TestSettingService_UpdateSettings_APIKeyACLTrustForwardedIPRefreshesConfig(t *testing.T) { repo := &settingUpdateRepoStub{} cfg := &config.Config{} @@ -555,11 +570,51 @@ func TestSettingService_UpdateSettings_APIKeyACLTrustForwardedIPRefreshesConfig( err := svc.UpdateSettings(context.Background(), &SystemSettings{ APIKeyACLTrustForwardedIP: true, + ForwardedClientIPHeaders: []string{" x-cdn-ip ", "X-CDN-IP", "true-client-ip"}, }) require.NoError(t, err) require.Equal(t, "true", repo.updates[SettingKeyAPIKeyACLTrustForwardedIP]) - require.True(t, cfg.Security.TrustForwardedIPForAPIKeyACL) - require.True(t, cfg.TrustForwardedIPForAPIKeyACL()) + require.JSONEq(t, `["X-Cdn-Ip","True-Client-Ip"]`, repo.updates[SettingKeyForwardedClientIPHeaders]) + runtimeSettings := cfg.ForwardedClientIPSettings() + require.True(t, runtimeSettings.TrustForwardedIP) + require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, runtimeSettings.Headers) + + runtimeSettings.Headers[0] = "X-Mutated" + require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, cfg.ForwardedClientIPSettings().Headers) +} + +func TestSettingService_UpdateSettings_RejectsInvalidForwardedClientIPHeadersWithoutRefreshing(t *testing.T) { + repo := &settingUpdateRepoStub{} + cfg := &config.Config{} + cfg.SetForwardedClientIPSettings(true, []string{"X-Existing-IP"}) + svc := NewSettingService(repo, cfg) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + ForwardedClientIPHeaders: []string{"X Invalid"}, + }) + + require.Error(t, err) + require.Nil(t, repo.updates) + runtimeSettings := cfg.ForwardedClientIPSettings() + require.True(t, runtimeSettings.TrustForwardedIP) + require.Equal(t, []string{"X-Existing-IP"}, runtimeSettings.Headers) +} + +func TestSettingService_UpdateSettings_WriteFailureDoesNotRefreshForwardedIPRuntime(t *testing.T) { + repo := &settingUpdateRepoStub{setMultipleErr: errors.New("database unavailable")} + cfg := &config.Config{} + cfg.SetForwardedClientIPSettings(false, []string{"X-Existing-IP"}) + svc := NewSettingService(repo, cfg) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + APIKeyACLTrustForwardedIP: true, + ForwardedClientIPHeaders: []string{"X-New-IP"}, + }) + + require.ErrorContains(t, err, "database unavailable") + runtimeSettings := cfg.ForwardedClientIPSettings() + require.False(t, runtimeSettings.TrustForwardedIP) + require.Equal(t, []string{"X-Existing-IP"}, runtimeSettings.Headers) } func TestSettingService_ParseSettings_APIKeyACLTrustForwardedIPFallsBackToConfigWhenMissing(t *testing.T) { @@ -582,7 +637,34 @@ func TestSettingService_ParseSettings_APIKeyACLTrustForwardedIPUsesStoredValue(t require.False(t, got.APIKeyACLTrustForwardedIP) } -func TestSettingService_LoadAPIKeyACLTrustForwardedIPSettingMigration(t *testing.T) { +func TestSettingService_ParseSettings_ForwardedClientIPHeaders(t *testing.T) { + cfg := &config.Config{} + cfg.SetForwardedClientIPSettings(true, []string{"X-Config-IP"}) + svc := NewSettingService(&settingUpdateRepoStub{}, cfg) + + t.Run("stored value is normalized", func(t *testing.T) { + got := svc.parseSettings(map[string]string{ + SettingKeyForwardedClientIPHeaders: `[" x-cdn-ip ","X-CDN-IP","true-client-ip"]`, + }) + require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, got.ForwardedClientIPHeaders) + }) + + t.Run("missing value falls back to config", func(t *testing.T) { + got := svc.parseSettings(map[string]string{}) + require.Equal(t, []string{"X-Config-IP"}, got.ForwardedClientIPHeaders) + }) + + t.Run("malformed value disables forwarded trust", func(t *testing.T) { + got := svc.parseSettings(map[string]string{ + SettingKeyAPIKeyACLTrustForwardedIP: "true", + SettingKeyForwardedClientIPHeaders: `{"not":"an array"}`, + }) + require.False(t, got.APIKeyACLTrustForwardedIP) + require.Empty(t, got.ForwardedClientIPHeaders) + }) +} + +func TestSettingService_LoadForwardedClientIPSettingsMigration(t *testing.T) { tests := []struct { name string values map[string]string @@ -630,19 +712,67 @@ func TestSettingService_LoadAPIKeyACLTrustForwardedIPSettingMigration(t *testing cfg.Security.TrustForwardedIPForAPIKeyACL = test.configDefault svc := NewSettingService(repo, cfg) - require.NoError(t, svc.LoadAPIKeyACLTrustForwardedIPSetting(context.Background())) + require.NoError(t, svc.LoadForwardedClientIPSettings(context.Background())) require.Equal(t, test.wantEnabled, cfg.TrustForwardedIPForAPIKeyACL()) require.Equal(t, test.wantForwardedIPUpdate, repo.updates[SettingKeyAPIKeyACLTrustForwardedIP]) + require.JSONEq(t, `[]`, repo.updates[SettingKeyForwardedClientIPHeaders]) if test.wantMigrationMarkerSet { require.Equal(t, "true", repo.updates[settingKeyForwardedClientIPModeV2]) } else { - require.Nil(t, repo.updates) + require.NotContains(t, repo.updates, settingKeyForwardedClientIPModeV2) } }) } } -func TestSettingService_LoadAPIKeyACLTrustForwardedIPSettingReadFailureFailsClosed(t *testing.T) { +func TestSettingService_LoadForwardedClientIPSettingsLoadsHeaders(t *testing.T) { + repo := &forwardedIPMigrationRepoStub{values: map[string]string{ + SettingKeyAPIKeyACLTrustForwardedIP: "true", + SettingKeyForwardedClientIPHeaders: `[" x-cdn-ip ","true-client-ip"]`, + settingKeyForwardedClientIPModeV2: "true", + }} + cfg := &config.Config{} + svc := NewSettingService(repo, cfg) + + require.NoError(t, svc.LoadForwardedClientIPSettings(context.Background())) + runtimeSettings := cfg.ForwardedClientIPSettings() + require.True(t, runtimeSettings.TrustForwardedIP) + require.Equal(t, []string{"X-Cdn-Ip", "True-Client-Ip"}, runtimeSettings.Headers) + require.Nil(t, repo.updates) +} + +func TestSettingService_LoadForwardedClientIPSettingsMalformedHeadersDisablesCustomTrust(t *testing.T) { + repo := &forwardedIPMigrationRepoStub{values: map[string]string{ + SettingKeyAPIKeyACLTrustForwardedIP: "true", + SettingKeyForwardedClientIPHeaders: `["X Invalid"]`, + }} + cfg := &config.Config{} + svc := NewSettingService(repo, cfg) + + err := svc.LoadForwardedClientIPSettings(context.Background()) + + require.ErrorContains(t, err, "load forwarded client ip headers") + runtimeSettings := cfg.ForwardedClientIPSettings() + require.False(t, runtimeSettings.TrustForwardedIP) + require.Empty(t, runtimeSettings.Headers) + require.Equal(t, "true", repo.updates[settingKeyForwardedClientIPModeV2]) + require.NotContains(t, repo.updates, SettingKeyAPIKeyACLTrustForwardedIP) +} + +func TestSettingService_LoadForwardedClientIPSettingsBackfillsConfigHeaders(t *testing.T) { + repo := &forwardedIPMigrationRepoStub{values: map[string]string{ + settingKeyForwardedClientIPModeV2: "true", + }} + cfg := &config.Config{} + cfg.SetForwardedClientIPSettings(false, []string{"X-Config-IP"}) + svc := NewSettingService(repo, cfg) + + require.NoError(t, svc.LoadForwardedClientIPSettings(context.Background())) + require.JSONEq(t, `["X-Config-IP"]`, repo.updates[SettingKeyForwardedClientIPHeaders]) + require.Equal(t, []string{"X-Config-IP"}, cfg.ForwardedClientIPSettings().Headers) +} + +func TestSettingService_LoadForwardedClientIPSettingsReadFailureFailsClosed(t *testing.T) { repo := &forwardedIPMigrationRepoStub{ getMultipleErr: errors.New("database unavailable"), } @@ -650,13 +780,15 @@ func TestSettingService_LoadAPIKeyACLTrustForwardedIPSettingReadFailureFailsClos cfg.SetTrustForwardedIPForAPIKeyACL(true) svc := NewSettingService(repo, cfg) - err := svc.LoadAPIKeyACLTrustForwardedIPSetting(context.Background()) + err := svc.LoadForwardedClientIPSettings(context.Background()) require.ErrorContains(t, err, "get forwarded client ip settings") - require.False(t, cfg.TrustForwardedIPForAPIKeyACL()) + runtimeSettings := cfg.ForwardedClientIPSettings() + require.False(t, runtimeSettings.TrustForwardedIP) + require.Empty(t, runtimeSettings.Headers) } -func TestSettingService_LoadAPIKeyACLTrustForwardedIPSettingWriteFailureUsesComputedMode(t *testing.T) { +func TestSettingService_LoadForwardedClientIPSettingsWriteFailureUsesComputedMode(t *testing.T) { tests := []struct { name string trustedProxiesSet bool @@ -675,7 +807,7 @@ func TestSettingService_LoadAPIKeyACLTrustForwardedIPSettingWriteFailureUsesComp cfg := &config.Config{Server: config.ServerConfig{TrustedProxiesConfigured: test.trustedProxiesSet}} svc := NewSettingService(repo, cfg) - err := svc.LoadAPIKeyACLTrustForwardedIPSetting(context.Background()) + err := svc.LoadForwardedClientIPSettings(context.Background()) require.ErrorContains(t, err, "migrate forwarded client ip setting") require.Equal(t, test.wantEnabled, cfg.TrustForwardedIPForAPIKeyACL()) diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 96c078e422..7449a3de69 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) @@ -62,6 +63,11 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting normalizedWhitelist = []string{} } settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist + normalizedForwardedClientIPHeaders, err := config.NormalizeForwardedClientIPHeaders(settings.ForwardedClientIPHeaders) + if err != nil { + return nil, infraerrors.BadRequest("INVALID_FORWARDED_CLIENT_IP_HEADERS", err.Error()) + } + settings.ForwardedClientIPHeaders = normalizedForwardedClientIPHeaders alipaySource, err := normalizeVisibleMethodSettingSource("alipay", settings.PaymentVisibleMethodAlipaySource, settings.PaymentVisibleMethodAlipayEnabled) if err != nil { return nil, err @@ -156,6 +162,11 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey } updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP) + forwardedClientIPHeadersJSON, err := json.Marshal(settings.ForwardedClientIPHeaders) + if err != nil { + return nil, fmt.Errorf("marshal forwarded client IP headers: %w", err) + } + updates[SettingKeyForwardedClientIPHeaders] = string(forwardedClientIPHeadersJSON) // LinuxDo Connect OAuth 登录 updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled) @@ -579,7 +590,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { }) } if s.cfg != nil { - s.cfg.SetTrustForwardedIPForAPIKeyACL(settings.APIKeyACLTrustForwardedIP) + s.cfg.SetForwardedClientIPSettings(settings.APIKeyACLTrustForwardedIP, settings.ForwardedClientIPHeaders) } // codex_cli_only 加固策略缓存:设置更新后强制下次重载(涉及 4 个键 + JSON 解析,直接置过期)。 s.codexRestrictionPolicySF.Forget("codex_restriction_policy") diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index f3ee815394..76f40890ca 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -613,8 +613,8 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit svc := NewSettingService(settingRepo, cfg) svc.SetDefaultSubscriptionGroupReader(groupRepo) svc.SetProxyRepository(proxyRepo) - if err := svc.LoadAPIKeyACLTrustForwardedIPSetting(context.Background()); err != nil { - logger.LegacyPrintf("service.setting", "Warning: load api key acl forwarded ip setting failed: %v", err) + if err := svc.LoadForwardedClientIPSettings(context.Background()); err != nil { + logger.LegacyPrintf("service.setting", "Warning: load forwarded client IP settings failed: %v", err) } if err := svc.MigrateOpenAIAllowClaudeCodeCodexPluginSetting(context.Background()); err != nil { logger.LegacyPrintf("service.setting", "Warning: migrate openai allow Claude Code Codex plugin setting failed: %v", err) From 3c86e249f407c0ed886cb43ad7d7fbf05dcf3f29 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:09:21 +0800 Subject: [PATCH 15/23] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20IP=20=E8=AF=B7=E6=B1=82=E5=A4=B4=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8E=A5=E5=8F=A3?= 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 --- .../internal/handler/admin/setting_handler.go | 1 + .../setting_handler_stepup_switch_test.go | 53 +++++++++++++++++++ .../handler/admin/setting_handler_update.go | 9 +++- backend/internal/handler/dto/settings.go | 9 ++-- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index d663cd61b3..6399c8eaf1 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -152,6 +152,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured, APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP, + ForwardedClientIPHeaders: settings.ForwardedClientIPHeaders, LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled, LinuxDoConnectClientID: settings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, diff --git a/backend/internal/handler/admin/setting_handler_stepup_switch_test.go b/backend/internal/handler/admin/setting_handler_stepup_switch_test.go index 22f7ebf469..676252fc93 100644 --- a/backend/internal/handler/admin/setting_handler_stepup_switch_test.go +++ b/backend/internal/handler/admin/setting_handler_stepup_switch_test.go @@ -2,6 +2,7 @@ package admin import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -155,3 +156,55 @@ func TestUpdateSettingsOmittedSecuritySwitchesKeepDisabled(t *testing.T) { require.Equal(t, "false", repo.values[service.SettingKeyStepUpEnabled]) require.Equal(t, "false", repo.values[service.SettingKeySessionBindingEnabled]) } + +func TestUpdateSettingsForwardedClientIPHeadersOmittedPreservesAndEmptyClears(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyForwardedClientIPHeaders: `["X-Cdn-Ip","True-Client-Ip"]`, + }) + + preserved := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil) + require.Equal(t, http.StatusOK, preserved.Code) + require.JSONEq(t, `["X-Cdn-Ip","True-Client-Ip"]`, repo.values[service.SettingKeyForwardedClientIPHeaders]) + require.Contains(t, preserved.Body.String(), `"forwarded_client_ip_headers":["X-Cdn-Ip","True-Client-Ip"]`) + + cleared := doUpdateSettings(t, h, map[string]any{"forwarded_client_ip_headers": []string{}}, nil) + require.Equal(t, http.StatusOK, cleared.Code) + require.JSONEq(t, `[]`, repo.values[service.SettingKeyForwardedClientIPHeaders]) + require.Contains(t, cleared.Body.String(), `"forwarded_client_ip_headers":[]`) +} + +func TestUpdateSettingsMalformedForwardedClientIPHeadersRemainFailClosedWhenOmitted(t *testing.T) { + cfg := &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}} + repo := &settingHandlerRepoStub{values: map[string]string{ + service.SettingKeyAPIKeyACLTrustForwardedIP: "true", + service.SettingKeyForwardedClientIPHeaders: `{"not":"an array"}`, + }} + svc := service.NewSettingService(repo, cfg) + require.ErrorContains(t, svc.LoadForwardedClientIPSettings(context.Background()), "load forwarded client ip headers") + require.False(t, cfg.ForwardedClientIPSettings().TrustForwardedIP) + h := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) + + rec := doUpdateSettings(t, h, map[string]any{"registration_enabled": true}, nil) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "false", repo.values[service.SettingKeyAPIKeyACLTrustForwardedIP]) + require.JSONEq(t, `[]`, repo.values[service.SettingKeyForwardedClientIPHeaders]) + runtimeSettings := cfg.ForwardedClientIPSettings() + require.False(t, runtimeSettings.TrustForwardedIP) + require.Empty(t, runtimeSettings.Headers) + require.Contains(t, rec.Body.String(), `"api_key_acl_trust_forwarded_ip":false`) + require.Contains(t, rec.Body.String(), `"forwarded_client_ip_headers":[]`) +} + +func TestUpdateSettingsRejectsInvalidForwardedClientIPHeader(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyForwardedClientIPHeaders: `["X-Existing-IP"]`, + }) + + rec := doUpdateSettings(t, h, map[string]any{ + "forwarded_client_ip_headers": []string{"X Invalid"}, + }, nil) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.JSONEq(t, `["X-Existing-IP"]`, repo.values[service.SettingKeyForwardedClientIPHeaders]) +} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 40343ed311..2f5792e213 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -51,7 +51,8 @@ type UpdateSettingsRequest struct { TurnstileSecretKey string `json:"turnstile_secret_key"` // API Key IP 访问控制设置 - APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"` + APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"` + ForwardedClientIPHeaders *[]string `json:"forwarded_client_ip_headers"` // LinuxDo Connect OAuth 登录 LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"` @@ -400,6 +401,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if req.StepUpEnabled != nil { stepUpEnabled = *req.StepUpEnabled } + forwardedClientIPHeaders := append([]string(nil), previousSettings.ForwardedClientIPHeaders...) + if req.ForwardedClientIPHeaders != nil { + forwardedClientIPHeaders = append([]string(nil), (*req.ForwardedClientIPHeaders)...) + } // 开启敏感操作 step-up 门控属自锁风险操作:仅允许本人已启用 TOTP 的管理员会话开启, // 否则开启后操作者立即被挡在所有敏感操作之外。仅在 false→true 的开启瞬间校验, @@ -1269,6 +1274,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.APIKeyACLTrustForwardedIP }(), + ForwardedClientIPHeaders: forwardedClientIPHeaders, LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, LinuxDoConnectClientID: req.LinuxDoConnectClientID, LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, @@ -1796,6 +1802,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { TurnstileSiteKey: updatedSettings.TurnstileSiteKey, TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured, APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP, + ForwardedClientIPHeaders: updatedSettings.ForwardedClientIPHeaders, LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled, LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index fd7e7b6a52..a62b9b07a7 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -52,10 +52,11 @@ type SystemSettings struct { SMTPFromName string `json:"smtp_from_name"` SMTPUseTLS bool `json:"smtp_use_tls"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"` - APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"` + APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"` + ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"` LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"` LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"` From fedeba2568b951206cd41b980bbc1111a0e0cf52 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:09:37 +0800 Subject: [PATCH 16/23] =?UTF-8?q?feat:=20=E5=AE=A1=E8=AE=A1=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20IP=20=E8=AF=B7=E6=B1=82=E5=A4=B4=E5=8F=98?= =?UTF-8?q?=E6=9B=B4?= 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/handler/admin/setting_handler_audit.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index 7d3a420ff4..c8b8bf1b24 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -107,6 +107,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP { changed = append(changed, "api_key_acl_trust_forwarded_ip") } + if !equalStringSlice(before.ForwardedClientIPHeaders, after.ForwardedClientIPHeaders) { + changed = append(changed, "forwarded_client_ip_headers") + } if before.LinuxDoConnectEnabled != after.LinuxDoConnectEnabled { changed = append(changed, "linuxdo_connect_enabled") } From ff5b0e6254a81247e4a16e0728aa8cf5ce0937b1 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:09:47 +0800 Subject: [PATCH 17/23] =?UTF-8?q?test:=20=E6=9B=B4=E6=96=B0=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20IP=20=E8=AE=BE=E7=BD=AE=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= 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/server/api_contract_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 86ac46c2af..bffce67cd6 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -791,6 +791,7 @@ func TestAPIContracts(t *testing.T) { "site_subtitle": "Subtitle", "api_base_url": "https://api.example.com", "api_key_acl_trust_forwarded_ip": false, + "forwarded_client_ip_headers": [], "contact_info": "support", "doc_url": "https://docs.example.com", "auth_source_default_email_balance": 0, @@ -1102,6 +1103,7 @@ func TestAPIContracts(t *testing.T) { "site_subtitle": "Subscription to API Conversion Platform", "api_base_url": "", "api_key_acl_trust_forwarded_ip": false, + "forwarded_client_ip_headers": [], "contact_info": "", "doc_url": "", "home_content": "", From 344f3039196c61c114ae73013dbf09c95abc22bb Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:09:57 +0800 Subject: [PATCH 18/23] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=AE=A2=E6=88=B7=E7=AB=AF=20IP=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= 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 --- frontend/src/api/admin/settings.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 33b1f7f97d..2779c2db62 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -455,6 +455,7 @@ export interface SystemSettings { turnstile_site_key: string; turnstile_secret_key_configured: boolean; api_key_acl_trust_forwarded_ip: boolean; + forwarded_client_ip_headers: string[]; // LinuxDo Connect OAuth settings linuxdo_connect_enabled: boolean; @@ -757,6 +758,7 @@ export interface UpdateSettingsRequest { turnstile_site_key?: string; turnstile_secret_key?: string; api_key_acl_trust_forwarded_ip?: boolean; + forwarded_client_ip_headers?: string[]; linuxdo_connect_enabled?: boolean; linuxdo_connect_client_id?: string; linuxdo_connect_client_secret?: string; From 4990ca0b2cb5b253a65fc0e0cf4dfc74040084c1 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:10:05 +0800 Subject: [PATCH 19/23] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20IP=20=E8=AF=B7=E6=B1=82=E5=A4=B4=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E7=95=8C=E9=9D=A2?= 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 --- frontend/src/views/admin/SettingsView.vue | 211 ++++++++++++++++++ .../admin/__tests__/SettingsView.spec.ts | 33 ++- 2 files changed, 243 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index c5c898ee85..bf749bb73b 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1654,6 +1654,66 @@ + +
+ +

+ {{ t("admin.settings.apiKeyAcl.forwardedClientIpHeadersHint") }} +

+
+
+ + {{ header }} + + +
+ +
+
+
+

+ {{ t("admin.settings.apiKeyAcl.forwardedClientIpHeadersRiskHint") }} +

+
@@ -7754,6 +7814,7 @@ const smtpPasswordManuallyEdited = ref(false); const testEmailAddress = ref(""); const registrationEmailSuffixWhitelistTags = ref([]); const registrationEmailSuffixWhitelistDraft = ref(""); +const forwardedClientIpHeaderDraft = ref(""); const tablePageSizeOptionsInput = ref("10, 20, 50, 100"); // Admin API Key 状态 @@ -8394,6 +8455,7 @@ const form = reactive({ turnstile_secret_key: "", turnstile_secret_key_configured: false, api_key_acl_trust_forwarded_ip: true, + forwarded_client_ip_headers: [], // LinuxDo Connect OAuth 登录 linuxdo_connect_enabled: false, linuxdo_connect_client_id: "", @@ -8972,6 +9034,143 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) { } } +const forwardedClientIpHeaderSeparatorKeys = new Set([ + " ", + ",", + ",", + "Enter", + "Tab", +]); +const forwardedClientIpHeaderTokenPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const maxForwardedClientIpHeaders = 16; + +type ForwardedClientIpHeaderResult = "added" | "duplicate" | "invalid" | "full"; + +function normalizeForwardedClientIpHeader(raw: string): string { + const header = raw.trim(); + if (!forwardedClientIpHeaderTokenPattern.test(header)) { + return ""; + } + + return header + .toLowerCase() + .split("-") + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join("-"); +} + +function normalizeForwardedClientIpHeaders(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + const headers: string[] = []; + const seen = new Set(); + for (const raw of value) { + if (typeof raw !== "string") { + continue; + } + const header = normalizeForwardedClientIpHeader(raw); + const key = header.toLowerCase(); + if (!header || seen.has(key) || headers.length >= maxForwardedClientIpHeaders) { + continue; + } + seen.add(key); + headers.push(header); + } + return headers; +} + +function removeForwardedClientIpHeader(header: string) { + form.forwarded_client_ip_headers = form.forwarded_client_ip_headers.filter( + (item) => item !== header, + ); +} + +function addForwardedClientIpHeader(raw: string): ForwardedClientIpHeaderResult { + const header = normalizeForwardedClientIpHeader(raw); + if (!header) { + return "invalid"; + } + if ( + form.forwarded_client_ip_headers.some( + (item) => item.toLowerCase() === header.toLowerCase(), + ) + ) { + return "duplicate"; + } + if (form.forwarded_client_ip_headers.length >= maxForwardedClientIpHeaders) { + return "full"; + } + form.forwarded_client_ip_headers = [ + ...form.forwarded_client_ip_headers, + header, + ]; + return "added"; +} + +function showForwardedClientIpHeaderError(result: ForwardedClientIpHeaderResult) { + if (result === "invalid") { + appStore.showError(t("admin.settings.apiKeyAcl.forwardedClientIpHeaderInvalid")); + } else if (result === "full") { + appStore.showError( + t("admin.settings.apiKeyAcl.forwardedClientIpHeadersLimit", { + max: maxForwardedClientIpHeaders, + }), + ); + } +} + +function commitForwardedClientIpHeaderDraft() { + const draft = forwardedClientIpHeaderDraft.value; + if (!draft) { + return; + } + const result = addForwardedClientIpHeader(draft); + showForwardedClientIpHeaderError(result); + forwardedClientIpHeaderDraft.value = ""; +} + +function handleForwardedClientIpHeaderKeydown(event: KeyboardEvent) { + if (event.isComposing) { + return; + } + if (forwardedClientIpHeaderSeparatorKeys.has(event.key)) { + event.preventDefault(); + commitForwardedClientIpHeaderDraft(); + return; + } + if ( + event.key === "Backspace" && + !forwardedClientIpHeaderDraft.value && + form.forwarded_client_ip_headers.length > 0 + ) { + form.forwarded_client_ip_headers.pop(); + } +} + +function handleForwardedClientIpHeaderPaste(event: ClipboardEvent) { + const text = event.clipboardData?.getData("text") || ""; + if (!text.trim()) { + return; + } + event.preventDefault(); + + let error: ForwardedClientIpHeaderResult | undefined; + for (const token of text.split(/[,,;\r\n]+/)) { + if (!token.trim()) { + continue; + } + const result = addForwardedClientIpHeader(token); + if (result === "invalid" || result === "full") { + error = result; + } + } + if (error) { + showForwardedClientIpHeaderError(error); + } +} + // Quota notify email helpers const addQuotaNotifyEmail = () => { if (!form.account_quota_notify_emails) { @@ -9354,6 +9553,10 @@ async function loadSettings() { normalizeRegistrationEmailSuffixDomains( settings.registration_email_suffix_whitelist, ); + form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders( + settings.forwarded_client_ip_headers, + ); + forwardedClientIpHeaderDraft.value = ""; tablePageSizeOptionsInput.value = formatTablePageSizeOptions( Array.isArray(settings.table_page_size_options) ? settings.table_page_size_options @@ -9595,6 +9798,9 @@ async function saveSettings() { form.login_agreement_mode = form.login_agreement_mode === "checkbox" ? "checkbox" : "modal"; form.login_agreement_documents = normalizedLoginAgreementDocuments; + form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders( + form.forwarded_client_ip_headers, + ); const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings( form.default_subscriptions, @@ -9728,6 +9934,7 @@ async function saveSettings() { turnstile_site_key: form.turnstile_site_key, turnstile_secret_key: form.turnstile_secret_key || undefined, api_key_acl_trust_forwarded_ip: form.api_key_acl_trust_forwarded_ip, + forwarded_client_ip_headers: form.forwarded_client_ip_headers, linuxdo_connect_enabled: form.linuxdo_connect_enabled, linuxdo_connect_client_id: form.linuxdo_connect_client_id, linuxdo_connect_client_secret: @@ -9993,6 +10200,10 @@ async function saveSettings() { normalizeRegistrationEmailSuffixDomains( updated.registration_email_suffix_whitelist, ); + form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders( + updated.forwarded_client_ip_headers, + ); + forwardedClientIpHeaderDraft.value = ""; tablePageSizeOptionsInput.value = formatTablePageSizeOptions( Array.isArray(updated.table_page_size_options) ? updated.table_page_size_options diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 8b7ffb301c..e913a1249a 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -368,6 +368,7 @@ const baseSettingsResponse = { turnstile_site_key: "", turnstile_secret_key_configured: false, api_key_acl_trust_forwarded_ip: true, + forwarded_client_ip_headers: [], linuxdo_connect_enabled: false, linuxdo_connect_client_id: "", linuxdo_connect_client_secret_configured: false, @@ -653,10 +654,11 @@ describe("admin SettingsView payment visible method controls", () => { expect(wrapper.text()).not.toContain("支付来源"); }); - it("loads and saves the forwarded client IP takeover switch", async () => { + it("loads, edits, validates, and saves forwarded client-IP headers", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, api_key_acl_trust_forwarded_ip: false, + forwarded_client_ip_headers: ["cf-connecting-ip", "X-Real-IP"], }); const wrapper = mountView(); @@ -669,14 +671,43 @@ describe("admin SettingsView payment visible method controls", () => { expect(card).toBeDefined(); const toggle = card!.get('input[type="checkbox"]'); expect((toggle.element as HTMLInputElement).checked).toBe(false); + expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false); await toggle.setValue(true); + expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(2); + expect(card!.text()).toContain("Cf-Connecting-Ip"); + expect(card!.text()).toContain("X-Real-Ip"); + showError.mockClear(); + + const input = card!.get('[data-testid="forwarded-client-ip-headers-input"]'); + await input.setValue("x-client-ip"); + await input.trigger("keydown", { key: "Enter" }); + await input.setValue("X-CLIENT-IP"); + await input.trigger("keydown", { key: "Enter" }); + await input.setValue("invalid header"); + await input.trigger("keydown", { key: "Enter" }); + expect(showError).toHaveBeenCalledTimes(1); + expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(3); + + const realIpTag = card! + .findAll('[data-testid="forwarded-client-ip-header-tag"]') + .find((tag) => tag.text().includes("X-Real-Ip")); + expect(realIpTag).toBeDefined(); + await realIpTag!.get("button").trigger("click"); + expect(card!.text()).not.toContain("X-Real-Ip"); + + await toggle.setValue(false); + expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false); + await toggle.setValue(true); + expect(card!.text()).toContain("X-Client-Ip"); + await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ api_key_acl_trust_forwarded_ip: true, + forwarded_client_ip_headers: ["Cf-Connecting-Ip", "X-Client-Ip"], }), ); }); From 80dd2929a309a99c7a3e6ed72ac4312df2f5c9a2 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:10:18 +0800 Subject: [PATCH 20/23] =?UTF-8?q?docs:=20=E6=9C=AC=E5=9C=B0=E5=8C=96?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=20IP=20=E8=AF=B7=E6=B1=82=E5=A4=B4?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE?= 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 --- frontend/src/i18n/locales/en/admin/settings.ts | 9 ++++++++- frontend/src/i18n/locales/zh/admin/settings.ts | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index c08ca2ae52..ec68ce1b10 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -153,7 +153,14 @@ export default { 'Choose which client IP is used by API Key allowlists/denylists, admin audit logs, and session IP/UA binding', trustForwardedIp: 'Trust forwarded client IP', trustForwardedIpHint: - 'Enabled by default for upgrade compatibility. When enabled, raw CF-Connecting-IP, X-Real-IP, or X-Forwarded-For values take over server.trusted_proxies for client-IP resolution. Disable it to enforce the Gin trusted-proxy chain configured by server.trusted_proxies. Only enable takeover mode when the origin cannot be reached directly. Changing this switch changes existing session IP fingerprints.' + 'Enabled by default for upgrade compatibility. When enabled, raw CF-Connecting-IP, X-Real-IP, or X-Forwarded-For values take over server.trusted_proxies for client-IP resolution. Disable it to enforce the Gin trusted-proxy chain configured by server.trusted_proxies. Only enable takeover mode when the origin cannot be reached directly. Changing this switch changes existing session IP fingerprints.', + forwardedClientIpHeaders: 'Custom client-IP headers', + forwardedClientIpHeadersHint: 'Add CDN or proxy header names to check before the built-in headers.', + forwardedClientIpHeadersPlaceholder: 'X-Client-IP', + forwardedClientIpHeadersRiskHint: 'These raw headers can be spoofed when the origin is reachable directly. Restrict origin access before trusting them.', + forwardedClientIpHeaderInvalid: 'Enter a valid HTTP header name.', + forwardedClientIpHeadersLimit: 'At most {max} custom client-IP headers are allowed.', + removeForwardedClientIpHeader: 'Remove {header}' }, linuxdo: { title: 'LinuxDo Connect Login', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index bfd3f139d5..f5827d0e1b 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -152,7 +152,14 @@ export default { description: '控制 API Key 白/黑名单、操作审计日志与会话 IP/UA 绑定使用哪个客户端 IP 判断', trustForwardedIp: '信任反代传递的客户端 IP', trustForwardedIpHint: - '为保证升级兼容默认开启。开启后 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For 会直接接管客户端 IP 解析并覆盖 server.trusted_proxies;关闭后严格使用 server.trusted_proxies 配置的 Gin 可信代理链。仅在源站无法被直接访问时开启接管模式。切换会改变现有会话的 IP 指纹。' + '为保证升级兼容默认开启。开启后 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For 会直接接管客户端 IP 解析并覆盖 server.trusted_proxies;关闭后严格使用 server.trusted_proxies 配置的 Gin 可信代理链。仅在源站无法被直接访问时开启接管模式。切换会改变现有会话的 IP 指纹。', + forwardedClientIpHeaders: '自定义客户端 IP 请求头', + forwardedClientIpHeadersHint: '添加 CDN 或反代请求头名称,解析时优先于内置请求头。', + forwardedClientIpHeadersPlaceholder: 'X-Client-IP', + forwardedClientIpHeadersRiskHint: '源站可被直接访问时,这些原始请求头可被伪造;请先限制源站访问再信任它们。', + forwardedClientIpHeaderInvalid: '请输入有效的 HTTP 请求头名称。', + forwardedClientIpHeadersLimit: '自定义客户端 IP 请求头最多允许 {max} 个。', + removeForwardedClientIpHeader: '移除 {header}' }, linuxdo: { title: 'LinuxDo Connect 登录', From 6becd11e3915577e5624e8651897a6d7aa821c7f Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:10:32 +0800 Subject: [PATCH 21/23] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20IP=20=E8=BE=B9=E7=BC=98=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= 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 --- deploy/EDGE_SECURITY.md | 45 ++++++++++++++++++++++++++++---------- deploy/config.example.yaml | 11 ++++++++++ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/deploy/EDGE_SECURITY.md b/deploy/EDGE_SECURITY.md index 0c0e78768a..956507c4fb 100644 --- a/deploy/EDGE_SECURITY.md +++ b/deploy/EDGE_SECURITY.md @@ -30,20 +30,41 @@ the application's responsibility. ## Trusted client IPs `security.trust_forwarded_ip_for_api_key_acl` is enabled by default for upgrade -compatibility. On the first upgrade to this mode, a legacy `false` value is -changed to `true` only when `server.trusted_proxies` was not explicitly -configured; explicit proxy policies remain in secure mode. Later administrator -changes are preserved. While enabled, raw `CF-Connecting-IP`, `X-Real-IP`, and -`X-Forwarded-For` values take over client-IP resolution for logs and -security-sensitive paths. Disable the switch to make Gin's -`server.trusted_proxies` chain authoritative. Configure only the exact CIDR/IP -addresses that connect directly to Sub2API; an explicit empty list trusts no -forwarded client IPs while the switch is disabled. +compatibility. While enabled, raw forwarding headers take over client-IP +resolution for logs and security-sensitive paths. Custom headers from +`security.forwarded_client_ip_headers` are checked in configured order before +the built-in `CF-Connecting-IP`, `X-Real-IP`, and `X-Forwarded-For` fallback. +Header names are case-insensitive, normalized when loaded, de-duplicated, and +limited to 16 unique valid HTTP field names. Header values must contain IP +literals; comma-separated values are supported, invalid entries are skipped, +and public addresses are preferred over private fallback addresses. + +The list can be supplied in YAML or with the comma-separated environment +variable `SECURITY_FORWARDED_CLIENT_IP_HEADERS`; an explicitly empty environment +value clears YAML values. It is also editable from the admin security settings +and updates at runtime without a restart. A request snapshots the switch and +header list together, so one request cannot mix old and new settings. Custom +headers are ignored completely when the switch is disabled. In that mode Gin's +`server.trusted_proxies` chain is authoritative: configure only the exact +CIDR/IP addresses that connect directly to Sub2API. An explicit empty list +trusts no forwarded client IPs. + +On the first upgrade to this mode, a legacy `false` value is changed to `true` +only when `server.trusted_proxies` was not explicitly configured; explicit +proxy policies remain in secure mode. New installations persist the configured +custom header list during database initialization. Existing installations +backfill a missing database value from the YAML configuration. A hidden +migration marker prevents later administrator changes from being overwritten. +If settings cannot be read or the persisted custom-header list is malformed, +the process fails closed to trusted-proxy mode with no custom headers. If a +migration write fails, the computed mode remains active for the current process +and startup records a warning. Compatibility takeover accepts forwarded headers without validating the direct -peer. Protect the origin from direct access while it is enabled. A CDN -deployment must firewall the origin so only the CDN or load balancer can reach -it, and the proxy must overwrite forwarded headers. +peer, including any configured custom header. Protect the origin from direct +access while it is enabled. A CDN deployment must firewall the origin so only +the CDN or load balancer can reach it, and that proxy must overwrite every +trusted client-IP header rather than append an untrusted client value. Example for a proxy on the same host: diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index e84a906d4d..c0fb225ee9 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -106,6 +106,17 @@ security: # 旧版兼容开关。开启时原始转发头会接管 server.trusted_proxies;关闭后严格 # 使用上方配置的可信代理链。示例配置采用高安全模式。 trust_forwarded_ip_for_api_key_acl: false + # Optional client-IP headers for third-party CDNs, checked in list order before + # CF-Connecting-IP, X-Real-IP, and X-Forwarded-For. Valid only while the legacy + # compatibility switch above is true; maximum 16 unique HTTP header names. + # 第三方 CDN 的可选客户端 IP 请求头,按列表顺序优先于内置请求头解析。 + # 仅在上方旧版兼容开关为 true 时生效;最多配置 16 个不重复的合法 HTTP 头名。 + # Environment / 环境变量: SECURITY_FORWARDED_CLIENT_IP_HEADERS=True-Client-IP,X-CDN-Client-IP + forwarded_client_ip_headers: [] + # Example / 示例: + # forwarded_client_ip_headers: + # - "True-Client-IP" + # - "X-CDN-Client-IP" url_allowlist: # Enable URL allowlist validation (disable to skip all URL checks) # 启用 URL 白名单验证(禁用则跳过所有 URL 检查) From 0cc382e3317d8b9de1eb66e669e756ff45e0327d Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:10:41 +0800 Subject: [PATCH 22/23] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20IP=20=E8=AF=B7=E6=B1=82=E5=A4=B4=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E8=AF=B4=E6=98=8E?= 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 --- README.md | 9 +++++++++ README_CN.md | 9 +++++++++ README_JA.md | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/README.md b/README.md index 16e7bd9a5d..1286413b9f 100644 --- a/README.md +++ b/README.md @@ -572,8 +572,17 @@ Additional security-related options are available in `config.yaml`: - `security.csp` to control Content-Security-Policy headers - `billing.circuit_breaker` to fail closed on billing errors - `security.trust_forwarded_ip_for_api_key_acl` enables legacy raw forwarded-header takeover (enabled by default for upgrade compatibility); disable it to enforce `server.trusted_proxies`, which should contain only the exact proxy CIDRs that connect directly to Sub2API +- `security.forwarded_client_ip_headers` configures up to 16 third-party CDN client-IP header names; they are checked in order before the built-in headers only while legacy takeover is enabled - `turnstile.required` to require Turnstile in release mode +Custom client-IP headers can be set in YAML or as a comma-separated environment variable: + +```bash +SECURITY_FORWARDED_CLIENT_IP_HEADERS=True-Client-IP,X-CDN-Client-IP +``` + +Header names are validated, canonicalized, and de-duplicated. The admin security settings can update the list without a restart; new installations persist YAML/environment defaults and existing installations backfill a missing database value. When legacy takeover is disabled, all custom and built-in raw forwarding headers are ignored and Gin uses only `server.trusted_proxies`. While takeover is enabled, firewall the origin to CDN/proxy addresses and make the edge overwrite every trusted client-IP header. See [`deploy/EDGE_SECURITY.md`](deploy/EDGE_SECURITY.md) for the complete migration and trust-boundary rules. + **⚠️ Security Warning: HTTP URL Configuration** When `security.url_allowlist.enabled=false`, the system performs minimal URL validation and **allows HTTP URLs by default** (dev-friendly mode; Docker Compose deployments use the same default). For production, explicitly tighten this to HTTPS-only: diff --git a/README_CN.md b/README_CN.md index a9aea3bbe8..b42d5516a0 100644 --- a/README_CN.md +++ b/README_CN.md @@ -608,8 +608,17 @@ gateway: - `security.csp` 配置 Content-Security-Policy - `billing.circuit_breaker` 计费异常时 fail-closed - `security.trust_forwarded_ip_for_api_key_acl` 控制旧版原始转发头接管(为升级兼容默认开启);关闭后严格使用 `server.trusted_proxies`,其中只应填写直接连接 Sub2API 的精确代理 CIDR +- `security.forwarded_client_ip_headers` 最多配置 16 个第三方 CDN 客户端 IP 请求头;仅在旧版接管开启时按顺序优先于内置请求头解析 - `turnstile.required` 在 release 模式强制启用 Turnstile +自定义客户端 IP 请求头可通过 YAML 配置,也可使用逗号分隔的环境变量: + +```bash +SECURITY_FORWARDED_CLIENT_IP_HEADERS=True-Client-IP,X-CDN-Client-IP +``` + +请求头名称会经过合法性校验、规范化和大小写无关去重。管理员可在安全设置中动态更新列表,无需重启;新安装会持久化 YAML/环境变量默认值,旧安装缺少数据库字段时会自动回填。关闭旧版接管后,自定义头和内置原始转发头均被忽略,只使用 `server.trusted_proxies`。开启接管时必须限制源站仅允许 CDN/代理访问,并确保边缘代理覆盖所有受信客户端 IP 请求头。完整迁移规则和信任边界见 [`deploy/EDGE_SECURITY.md`](deploy/EDGE_SECURITY.md)。 + **网关防御纵深建议(重点)** - `gateway.upstream_response_read_max_bytes`:限制非流式上游响应读取大小(默认 `8MB`),用于防止异常响应导致内存放大。 diff --git a/README_JA.md b/README_JA.md index 8d95162612..37ad6a2dc4 100644 --- a/README_JA.md +++ b/README_JA.md @@ -570,8 +570,17 @@ default: - `security.csp` - Content-Security-Policy ヘッダーの制御 - `billing.circuit_breaker` - 課金エラー時にフェイルクローズ - `security.trust_forwarded_ip_for_api_key_acl` - 従来の生転送ヘッダーによる上書きを制御(アップグレード互換性のため既定で有効)。無効にすると `server.trusted_proxies` を厳格に使用し、Sub2API に直接接続するプロキシの正確な CIDR のみを指定 +- `security.forwarded_client_ip_headers` - サードパーティ CDN のクライアント IP ヘッダーを最大 16 個指定。従来モードが有効な場合のみ、設定順で組み込みヘッダーより先に評価 - `turnstile.required` - リリースモードでの Turnstile 必須化 +カスタムクライアント IP ヘッダーは YAML またはカンマ区切りの環境変数で設定できます: + +```bash +SECURITY_FORWARDED_CLIENT_IP_HEADERS=True-Client-IP,X-CDN-Client-IP +``` + +ヘッダー名は検証、正規化、大小文字を区別しない重複排除が行われます。管理画面のセキュリティ設定から再起動せずに更新でき、新規インストールでは YAML/環境変数の既定値を保存し、既存環境ではデータベース値がない場合に補完します。従来モードを無効にするとカスタムおよび組み込みの生転送ヘッダーはすべて無視され、`server.trusted_proxies` のみを使用します。有効にする場合はオリジンへの接続元を CDN/プロキシに制限し、エッジで信頼する全クライアント IP ヘッダーを上書きしてください。移行規則と信頼境界の詳細は [`deploy/EDGE_SECURITY.md`](deploy/EDGE_SECURITY.md) を参照してください。 + **⚠️ セキュリティ警告: HTTP URL 設定** `security.url_allowlist.enabled=false` の場合、システムは最小限の URL バリデーションのみを行い、**デフォルトで HTTP URL を許可**します(開発フレンドリーモード。Docker Compose デプロイのデフォルトも同じです)。本番環境では、以下のように明示的に HTTPS のみに制限することを推奨します: From dd0cbe91c9c847b541d199c30cc3d08f4ee9fe04 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:22:45 +0800 Subject: [PATCH 23/23] =?UTF-8?q?fix:=20=E9=81=BF=E5=85=8D=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E5=AE=A2=E6=88=B7=E7=AB=AF=20IP=20=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= 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/config/config.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index c60ef2a461..c5165c7c05 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -685,9 +685,9 @@ type SecurityConfig struct { ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"` // TrustForwardedIPForAPIKeyACL enables legacy raw forwarded-header takeover. // When disabled, server.trusted_proxies is authoritative for all client-IP consumers. - TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"` - ForwardedClientIPHeaders []string `mapstructure:"forwarded_client_ip_headers" json:"forwarded_client_ip_headers" yaml:"forwarded_client_ip_headers"` - forwardedClientIPSettingsLive atomic.Pointer[ForwardedClientIPSettings] `mapstructure:"-" json:"-" yaml:"-"` + TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"` + ForwardedClientIPHeaders []string `mapstructure:"forwarded_client_ip_headers" json:"forwarded_client_ip_headers" yaml:"forwarded_client_ip_headers"` + forwardedClientIPSettingsLive *atomic.Pointer[ForwardedClientIPSettings] `mapstructure:"-" json:"-" yaml:"-"` } func NormalizeForwardedClientIPHeaders(headers []string) ([]string, error) { @@ -723,10 +723,13 @@ func (c *Config) ForwardedClientIPSettings() ForwardedClientIPSettings { if c == nil { return ForwardedClientIPSettings{Headers: []string{}} } - if snapshot := c.Security.forwardedClientIPSettingsLive.Load(); snapshot != nil { - return ForwardedClientIPSettings{ - TrustForwardedIP: snapshot.TrustForwardedIP, - Headers: cloneForwardedClientIPHeaders(snapshot.Headers), + live := c.Security.forwardedClientIPSettingsLive + if live != nil { + if snapshot := live.Load(); snapshot != nil { + return ForwardedClientIPSettings{ + TrustForwardedIP: snapshot.TrustForwardedIP, + Headers: cloneForwardedClientIPHeaders(snapshot.Headers), + } } } return ForwardedClientIPSettings{ @@ -750,6 +753,9 @@ func (c *Config) SetForwardedClientIPSettings(enabled bool, headers []string) { return } headers = cloneForwardedClientIPHeaders(headers) + if c.Security.forwardedClientIPSettingsLive == nil { + c.Security.forwardedClientIPSettingsLive = &atomic.Pointer[ForwardedClientIPSettings]{} + } c.Security.forwardedClientIPSettingsLive.Store(&ForwardedClientIPSettings{ TrustForwardedIP: enabled, Headers: headers,