diff --git a/README.md b/README.md
index 0c18411c5..e37415ce0 100644
--- a/README.md
+++ b/README.md
@@ -571,9 +571,18 @@ 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
+- `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 33e35e79d..8ec654bb1 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -607,9 +607,18 @@ gateway:
- `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单)
- `security.csp` 配置 Content-Security-Policy
- `billing.circuit_breaker` 计费异常时 fail-closed
-- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For
+- `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 0506dfb59..a1b53cb53 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -569,9 +569,18 @@ default:
- `security.response_headers.enabled` - 設定可能なレスポンスヘッダーフィルタリングを有効化(無効時はデフォルトの許可リストを使用)
- `security.csp` - Content-Security-Policy ヘッダーの制御
- `billing.circuit_breaker` - 課金エラー時にフェイルクローズ
-- `server.trusted_proxies` - X-Forwarded-For パースの有効化
+- `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 のみに制限することを推奨します:
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 38ebc9427..c5165c7c0 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 (
@@ -639,17 +641,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 配置
+ 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 配置
@@ -667,36 +670,103 @@ 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"`
- 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"`
+ 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{}}
+ }
+ live := c.Security.forwardedClientIPSettingsLive
+ if live != nil {
+ if snapshot := live.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 {
+ return c.ForwardedClientIPSettings().TrustForwardedIP
+}
+
+// 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) SetForwardedClientIPSettings(enabled bool, headers []string) {
if c == nil {
- return false
+ return
}
- live := c.Security.trustForwardedIPForAPIKeyACLLive
- if live == nil {
- return c.Security.TrustForwardedIPForAPIKeyACL
+ headers = cloneForwardedClientIPHeaders(headers)
+ if c.Security.forwardedClientIPSettingsLive == nil {
+ c.Security.forwardedClientIPSettingsLive = &atomic.Pointer[ForwardedClientIPSettings]{}
}
- return live.Load()
+ 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 {
@@ -1564,11 +1634,22 @@ 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
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, ","))
+ }
+ if forwardedClientIPHeadersEnvConfigured {
+ cfg.Security.ForwardedClientIPHeaders = normalizeStringSlice(strings.Split(forwardedClientIPHeadersEnv, ","))
+ }
+ cfg.Server.TrustedProxiesConfigured = trustedProxiesConfigured
if cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs == 0 {
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
}
@@ -1624,7 +1705,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)
@@ -1714,7 +1800,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秒空闲超时
- viper.SetDefault("server.trusted_proxies", []string{})
viper.SetDefault("server.max_request_body_size", int64(256*1024*1024))
// H2C 默认配置
viper.SetDefault("server.h2c.enabled", false)
@@ -1771,7 +1856,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)
@@ -2229,6 +2314,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 36ae89ffa..dece5fd5a 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"
@@ -41,12 +43,207 @@ 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.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 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{})
+
+ 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) {
viper.Reset()
t.Setenv("JWT_SECRET", "")
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index d663cd61b..6399c8eaf 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_audit.go b/backend/internal/handler/admin/setting_handler_audit.go
index 7d3a420ff..c8b8bf1b2 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")
}
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 22f7ebf46..676252fc9 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 40343ed31..2f5792e21 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 fd7e7b6a5..a62b9b07a 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"`
diff --git a/backend/internal/pkg/ip/ip.go b/backend/internal/pkg/ip/ip.go
index b9c777449..ff1347d80 100644
--- a/backend/internal/pkg/ip/ip.go
+++ b/backend/internal/pkg/ip/ip.go
@@ -8,10 +8,138 @@ 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.
+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) {
+ 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 {
+ settings, ok := requestForwardedIPSettings(c)
+ return !ok || settings.trustForwarded
+}
+
+// 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 ""
+ }
+ if !requestUsesLegacyForwardedIPTrust(c) {
+ 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, fallback
+ }
+ }
+ if realIP := normalizeIP(c.GetHeader("X-Real-IP")); realIP != "" {
+ if fallback == "" {
+ fallback = realIP
+ }
+ if !isPrivateIP(realIP) {
+ return realIP, fallback
+ }
+ }
+ 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), fallback
+ }
+ }
+ if fallback == "" && len(ips) > 0 {
+ fallback = normalizeIP(strings.TrimSpace(ips[0]))
+ }
+ }
+ return "", fallback
}
// GetTrustedClientIP 从 Gin 的可信代理解析链提取客户端 IP。
@@ -24,10 +152,17 @@ 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.
-func GetSecurityClientIP(c *gin.Context, _ bool) string {
+// GetSecurityClientIP returns the address used by security-sensitive paths.
+// 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 requestSettings, ok := requestForwardedIPSettings(c); ok {
+ trustForwarded = requestSettings.trustForwarded
+ }
+ if trustForwarded {
+ return GetClientIP(c)
+ }
return GetTrustedClientIP(c)
}
@@ -41,6 +176,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 +252,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 2432abe7a..505ade5ee 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"})
@@ -53,41 +73,125 @@ 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 {
+ 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)
+
+ require.Equal(t, 200, w.Code)
+ require.Equal(t, "1.2.3.4", w.Body.String())
+}
+
+func TestGetSecurityClientIPCustomHeaderPrecedenceAndFallback(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ tests := []struct {
name string
- trustForwarded bool
+ trustForward bool
+ headers []string
+ requestHeaders map[string]string
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) {
+ {
+ 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) {
- c.String(200, GetSecurityClientIP(c, tc.trustForwarded))
+ 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"
- req.Header.Set("X-Real-IP", "1.2.3.4")
+ for name, value := range test.requestHeaders {
+ req.Header.Set(name, value)
+ }
r.ServeHTTP(w, req)
- require.Equal(t, 200, w.Code)
- require.Equal(t, tc.want, w.Body.String())
+ require.Equal(t, test.want, 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)
@@ -97,3 +201,76 @@ 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 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)
+
+ 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())
+ })
+ }
+}
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 86ac46c2a..bffce67cd 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": "",
diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go
index 65548c9e2..fef7cd056 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 63cf7a047..5e986bbd7 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) })
diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go
index a3216f8c2..4d6d9b524 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 e535693c2..19fca2203 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) {
+ 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, cfg.TrustForwardedIPForAPIKeyACL()),
+ IP: ip.GetSecurityClientIP(c, forwardedIPSettings.TrustForwardedIP),
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 839983623..08e944878 100644
--- a/backend/internal/server/middleware/session_binding_test.go
+++ b/backend/internal/server/middleware/session_binding_test.go
@@ -8,29 +8,32 @@ 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"
"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())
@@ -53,6 +56,39 @@ func TestSessionBindingContextDoesNotTrustHeadersWithoutTrustedProxy(t *testing.
}
}
+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()
diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go
index 47bffbef2..e10672d83 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))
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index cbbb4b785..e503cfaca 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -165,6 +165,8 @@ 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 双因素认证设置
SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能
diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go
index a56e57784..adc1195c0 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{
@@ -54,7 +62,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyLoginAgreementMode: defaultLoginAgreementMode,
SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate,
SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON,
- SettingKeyAPIKeyACLTrustForwardedIP: "false",
+ SettingKeyAPIKeyACLTrustForwardedIP: "true",
+ SettingKeyForwardedClientIPHeaders: string(forwardedClientIPHeadersJSON),
+ settingKeyForwardedClientIPModeV2: "true",
SettingKeySiteName: "Sub2API",
SettingKeySiteLogo: "",
SettingKeyPurchaseSubscriptionEnabled: "false",
@@ -237,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"
@@ -246,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.Security.TrustForwardedIPForAPIKeyACL
+ }
+ 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",
@@ -277,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 52acc62cf..5f02110b4 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -2,6 +2,7 @@ package service
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"sync/atomic"
@@ -216,21 +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
}
- value, err := s.settingRepo.GetValue(ctx, SettingKeyAPIKeyACLTrustForwardedIP)
+
+ values, err := s.settingRepo.GetMultiple(ctx, []string{
+ SettingKeyAPIKeyACLTrustForwardedIP,
+ SettingKeyForwardedClientIPHeaders,
+ 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.SetForwardedClientIPSettings(false, nil)
+ return fmt.Errorf("get forwarded client ip settings: %w", err)
}
- enabled := value == "true"
- s.cfg.SetTrustForwardedIPForAPIKeyACL(enabled)
- return nil
+
+ enabled := s.cfg.Security.TrustForwardedIPForAPIKeyACL
+ headers := s.cfg.ForwardedClientIPSettings().Headers
+ storedValue, hasStoredValue := values[SettingKeyAPIKeyACLTrustForwardedIP]
+ if hasStoredValue {
+ enabled = storedValue == "true"
+ }
+
+ 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)
+ }
+ }
+
+ 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 93cf8284a..3a4050f5b 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"
@@ -16,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) {
@@ -40,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) {
@@ -87,6 +89,62 @@ 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, 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 {
+ 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
}
@@ -495,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{}
@@ -502,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) {
@@ -519,6 +627,194 @@ 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_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
+ 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.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.NotContains(t, repo.updates, settingKeyForwardedClientIPModeV2)
+ }
+ })
+ }
+}
+
+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"),
+ }
+ cfg := &config.Config{}
+ cfg.SetTrustForwardedIPForAPIKeyACL(true)
+ svc := NewSettingService(repo, cfg)
+
+ err := svc.LoadForwardedClientIPSettings(context.Background())
+
+ require.ErrorContains(t, err, "get forwarded client ip settings")
+ runtimeSettings := cfg.ForwardedClientIPSettings()
+ require.False(t, runtimeSettings.TrustForwardedIP)
+ require.Empty(t, runtimeSettings.Headers)
+}
+
+func TestSettingService_LoadForwardedClientIPSettingsWriteFailureUsesComputedMode(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.LoadForwardedClientIPSettings(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{
diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go
index 96c078e42..7449a3de6 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/settings_view.go b/backend/internal/service/settings_view.go
index 85ab619d1..c775e2ee7 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
diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go
index f3ee81539..76f40890c 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)
diff --git a/deploy/EDGE_SECURITY.md b/deploy/EDGE_SECURITY.md
index 0054253eb..956507c4f 100644
--- a/deploy/EDGE_SECURITY.md
+++ b/deploy/EDGE_SECURITY.md
@@ -29,13 +29,42 @@ 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.
+`security.trust_forwarded_ip_for_api_key_acl` is enabled by default for upgrade
+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.
-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.
+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, 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 24fa44515..c0fb225ee 100644
--- a/deploy/config.example.yaml
+++ b/deploy/config.example.yaml
@@ -36,9 +36,15 @@ 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 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.1/32
+ - ::1/128
# Global max request body size in bytes (default: 256MB)
# 全局最大请求体大小(字节,默认 256MB)
# Applies to all requests, especially important for h2c first request memory protection
@@ -95,6 +101,22 @@ 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
+ # 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 检查)
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index 33b1f7f97..2779c2db6 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;
diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts
index 531769eec..ec68ce1b1 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:
- '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.',
+ 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 f656ac488..f5827d0e1 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:
- '默认关闭。仅在源站只允许 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 指纹。',
+ forwardedClientIpHeaders: '自定义客户端 IP 请求头',
+ forwardedClientIpHeadersHint: '添加 CDN 或反代请求头名称,解析时优先于内置请求头。',
+ forwardedClientIpHeadersPlaceholder: 'X-Client-IP',
+ forwardedClientIpHeadersRiskHint: '源站可被直接访问时,这些原始请求头可被伪造;请先限制源站访问再信任它们。',
+ forwardedClientIpHeaderInvalid: '请输入有效的 HTTP 请求头名称。',
+ forwardedClientIpHeadersLimit: '自定义客户端 IP 请求头最多允许 {max} 个。',
+ removeForwardedClientIpHeader: '移除 {header}'
},
linuxdo: {
title: 'LinuxDo Connect 登录',
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index b2b1c760c..bf749bb73 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -1654,6 +1654,66 @@
+ {{ t("admin.settings.apiKeyAcl.forwardedClientIpHeadersHint") }} +
++ {{ t("admin.settings.apiKeyAcl.forwardedClientIpHeadersRiskHint") }} +
+