Merge pull request #4604 from BenjaminAaron196/codex/fix-issue-4600-client-ip

fix: 修复客户端 IP 回退并支持自定义 CDN 请求头
This commit is contained in:
Wesley Liddick
2026-07-20 09:20:02 +08:00
committed by GitHub
33 changed files with 1675 additions and 117 deletions
+10 -1
View File
@@ -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:
+10 -1
View File
@@ -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`),用于防止异常响应导致内存放大。
+10 -1
View File
@@ -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 のみに制限することを推奨します:
+122 -31
View File
@@ -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")
}
+197
View File
@@ -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", "")
@@ -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,
@@ -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")
}
@@ -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])
}
@@ -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,
+5 -4
View File
@@ -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"`
+176 -7
View File
@@ -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"
+190 -13
View File
@@ -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())
})
}
}
@@ -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": "",
+20 -12
View File
@@ -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)
@@ -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) })
@@ -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"}))
@@ -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) != "" {
@@ -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()
+1 -1
View File
@@ -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))
@@ -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 功能
+43 -3
View File
@@ -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"),
+56 -10
View File
@@ -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 获取所有系统设置
@@ -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{
+12 -1
View File
@@ -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")
@@ -42,6 +42,7 @@ type SystemSettings struct {
TurnstileSecretKey string
TurnstileSecretKeyConfigured bool
APIKeyACLTrustForwardedIP bool
ForwardedClientIPHeaders []string
// LinuxDo Connect OAuth 登录
LinuxDoConnectEnabled bool
+2 -2
View File
@@ -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)
+35 -6
View File
@@ -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:
+25 -3
View File
@@ -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 检查)
+2
View File
@@ -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;
@@ -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',
@@ -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 登录',
+212 -1
View File
@@ -1654,6 +1654,66 @@
</div>
<Toggle v-model="form.api_key_acl_trust_forwarded_ip" />
</div>
<div
v-if="form.api_key_acl_trust_forwarded_ip"
class="border-t border-gray-100 pt-4 dark:border-dark-700"
>
<label
for="forwarded-client-ip-headers"
class="font-medium text-gray-900 dark:text-white"
>
{{ t("admin.settings.apiKeyAcl.forwardedClientIpHeaders") }}
</label>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.apiKeyAcl.forwardedClientIpHeadersHint") }}
</p>
<div
class="mt-3 rounded-lg border border-gray-300 bg-white p-2 dark:border-dark-500 dark:bg-dark-700"
>
<div class="flex flex-wrap items-center gap-2">
<span
v-for="header in form.forwarded_client_ip_headers"
:key="header"
data-testid="forwarded-client-ip-header-tag"
class="inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-700 dark:bg-dark-600 dark:text-gray-200"
>
<span>{{ header }}</span>
<button
type="button"
class="rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-dark-500 dark:hover:text-white"
:aria-label="t('admin.settings.apiKeyAcl.removeForwardedClientIpHeader', { header })"
@click="removeForwardedClientIpHeader(header)"
>
<Icon
name="x"
size="xs"
class="h-3.5 w-3.5"
:stroke-width="2"
/>
</button>
</span>
<div
class="flex min-w-[220px] flex-1 items-center gap-1 rounded border border-transparent px-2 py-1 focus-within:border-primary-300 dark:focus-within:border-primary-700"
>
<input
id="forwarded-client-ip-headers"
v-model="forwardedClientIpHeaderDraft"
data-testid="forwarded-client-ip-headers-input"
type="text"
class="w-full bg-transparent text-sm font-mono text-gray-900 outline-none placeholder:text-gray-400 dark:text-white dark:placeholder:text-gray-500"
:placeholder="t('admin.settings.apiKeyAcl.forwardedClientIpHeadersPlaceholder')"
@keydown="handleForwardedClientIpHeaderKeydown"
@blur="commitForwardedClientIpHeaderDraft"
@paste="handleForwardedClientIpHeaderPaste"
/>
</div>
</div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.apiKeyAcl.forwardedClientIpHeadersRiskHint") }}
</p>
</div>
</div>
</div>
@@ -7754,6 +7814,7 @@ const smtpPasswordManuallyEdited = ref(false);
const testEmailAddress = ref("");
const registrationEmailSuffixWhitelistTags = ref<string[]>([]);
const registrationEmailSuffixWhitelistDraft = ref("");
const forwardedClientIpHeaderDraft = ref("");
const tablePageSizeOptionsInput = ref("10, 20, 50, 100");
// Admin API Key 状态
@@ -8393,7 +8454,8 @@ const form = reactive<SettingsForm>({
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,
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<string>();
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
@@ -367,6 +367,8 @@ const baseSettingsResponse = {
turnstile_enabled: false,
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,
@@ -652,6 +654,64 @@ describe("admin SettingsView payment visible method controls", () => {
expect(wrapper.text()).not.toContain("支付来源");
});
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();
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);
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"],
}),
);
});
it("links payment guidance to README sections instead of removed payment docs", async () => {
const wrapper = mountView();