mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:38:22 +08:00
Merge pull request #4604 from BenjaminAaron196/codex/fix-issue-4600-client-ip
fix: 修复客户端 IP 回退并支持自定义 CDN 请求头
This commit is contained in:
@@ -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
@@ -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
@@ -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 のみに制限することを推奨します:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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"),
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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 检查)
|
||||
|
||||
@@ -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 登录',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user