From ec5a34593561518f974b0c3eff6d72808400194e Mon Sep 17 00:00:00 2001 From: xuhaihan Date: Wed, 19 Aug 2026 21:10:08 +0800 Subject: [PATCH] fix(proxy): validate configurable probe targets --- backend/internal/config/config.go | 43 ++++++++++++- .../internal/config/env_reachability_test.go | 16 ++++- .../config/proxy_probe_config_test.go | 47 ++++++++++++++ .../repository/proxy_probe_service.go | 61 ++++++++++--------- 4 files changed, 137 insertions(+), 30 deletions(-) create mode 100644 backend/internal/config/proxy_probe_config_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f6bafa1ee..0fa0058a5 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -842,6 +842,43 @@ type ProbeURLConfig struct { Parser string `mapstructure:"parser"` // "ip-api" / "ipify" / "chatgpt-trace" } +func normalizeProxyProbeURLs(targets []ProbeURLConfig) ([]ProbeURLConfig, error) { + if len(targets) == 0 { + return nil, nil + } + + normalized := make([]ProbeURLConfig, 0, len(targets)) + for i, target := range targets { + rawURL := strings.TrimSpace(target.URL) + parser := strings.ToLower(strings.TrimSpace(target.Parser)) + if rawURL == "" { + return nil, fmt.Errorf("entry %d: url is required", i) + } + if parser == "" { + return nil, fmt.Errorf("entry %d: parser is required", i) + } + switch parser { + case "ip-api", "ipify", "chatgpt-trace": + default: + return nil, fmt.Errorf("entry %d: unsupported parser %q", i, target.Parser) + } + + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" { + return nil, fmt.Errorf("entry %d: invalid url %q", i, target.URL) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("entry %d: url scheme must be http or https", i) + } + + normalized = append(normalized, ProbeURLConfig{ + URL: rawURL, + Parser: parser, + }) + } + return normalized, nil +} + type BillingConfig struct { CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"` // MinimumBalanceReserve is the conservative preflight floor for balance billing. @@ -2513,7 +2550,6 @@ func setEnvReachableDefaults() { viper.SetDefault("gateway.session_idle_timeout_minutes", 0) viper.SetDefault("gateway.user_message_queue.mode", "") viper.SetDefault("update.proxy_url", "") - viper.SetDefault("security.proxy_probe.urls", []ProbeURLConfig{}) // sticky_escape_enabled is the one exception to the zero-value rule: its // effective default is true, applied post-unmarshal via a viper.IsSet guard. @@ -2580,6 +2616,11 @@ func (c *Config) Validate() error { } c.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders c.SetForwardedClientIPSettings(c.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders) + proxyProbeURLs, err := normalizeProxyProbeURLs(c.Security.ProxyProbe.URLs) + if err != nil { + return fmt.Errorf("security.proxy_probe.urls: %w", err) + } + c.Security.ProxyProbe.URLs = proxyProbeURLs if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 { return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds") } diff --git a/backend/internal/config/env_reachability_test.go b/backend/internal/config/env_reachability_test.go index 8141a2ec3..cfe39587e 100644 --- a/backend/internal/config/env_reachability_test.go +++ b/backend/internal/config/env_reachability_test.go @@ -45,6 +45,20 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin // is out of scope here — such settings need a config file either way. continue } + if ft.Kind() == reflect.Slice { + elem := ft.Elem() + for elem.Kind() == reflect.Ptr { + elem = elem.Elem() + } + if elem.Kind() == reflect.Struct { + // AutomaticEnv exposes one string value. Viper's string-to-slice + // hook can populate scalar slices, but it cannot decode a string + // into []struct. Registering a default would turn silent ignore + // into a startup unmarshal error, so structured slices remain + // config-file-only just like maps. + continue + } + } out[strings.ToLower(key)] = ft.String() } } @@ -62,7 +76,7 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin // were lost, silently disabling async image tasks for env-driven deployments. // // When this fails, register a zero-valued default in setEnvReachableDefaults -// for each reported key. +// for each reported scalar key. Maps and slices of structs are config-file-only. func TestConfigKeysAreEnvReachable(t *testing.T) { bound := map[string]string{} collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound) diff --git a/backend/internal/config/proxy_probe_config_test.go b/backend/internal/config/proxy_probe_config_test.go new file mode 100644 index 000000000..af5b97209 --- /dev/null +++ b/backend/internal/config/proxy_probe_config_test.go @@ -0,0 +1,47 @@ +//go:build unit + +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeProxyProbeURLs(t *testing.T) { + t.Parallel() + + got, err := normalizeProxyProbeURLs([]ProbeURLConfig{ + {URL: " https://chatgpt.com/cdn-cgi/trace ", Parser: " CHATGPT-TRACE "}, + {URL: "https://api64.ipify.org?format=json", Parser: "ipify"}, + }) + require.NoError(t, err) + require.Equal(t, []ProbeURLConfig{ + {URL: "https://chatgpt.com/cdn-cgi/trace", Parser: "chatgpt-trace"}, + {URL: "https://api64.ipify.org?format=json", Parser: "ipify"}, + }, got) +} + +func TestNormalizeProxyProbeURLsRejectsInvalidEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target ProbeURLConfig + wantErr string + }{ + {name: "missing URL", target: ProbeURLConfig{Parser: "ipify"}, wantErr: "url is required"}, + {name: "missing parser", target: ProbeURLConfig{URL: "https://example.com"}, wantErr: "parser is required"}, + {name: "unknown parser", target: ProbeURLConfig{URL: "https://example.com", Parser: "ip_api"}, wantErr: "unsupported parser"}, + {name: "relative URL", target: ProbeURLConfig{URL: "/cdn-cgi/trace", Parser: "chatgpt-trace"}, wantErr: "invalid url"}, + {name: "unsupported scheme", target: ProbeURLConfig{URL: "ftp://example.com/file", Parser: "ipify"}, wantErr: "scheme must be http or https"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := normalizeProxyProbeURLs([]ProbeURLConfig{tt.target}) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index 7ecb26cfd..beae186bd 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -31,18 +31,15 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { if insecure { log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.") } - // 构建探测 URL 列表:优先用配置的自定义列表,否则用内置默认列表 - probeTargets := defaultProbeURLs + // 构建探测 URL 列表:配置存在时覆盖内置默认列表。 + var configuredTargets []configuredProbeTarget if cfg != nil && len(cfg.Security.ProxyProbe.URLs) > 0 { - probeTargets = make([]probeTarget, 0, len(cfg.Security.ProxyProbe.URLs)) + configuredTargets = make([]configuredProbeTarget, 0, len(cfg.Security.ProxyProbe.URLs)) for _, u := range cfg.Security.ProxyProbe.URLs { - if strings.TrimSpace(u.URL) == "" || strings.TrimSpace(u.Parser) == "" { - continue - } - probeTargets = append(probeTargets, probeTarget{url: u.URL, parser: u.Parser}) - } - if len(probeTargets) == 0 { - probeTargets = defaultProbeURLs + configuredTargets = append(configuredTargets, configuredProbeTarget{ + url: u.URL, + parser: u.Parser, + }) } } @@ -51,7 +48,7 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { allowPrivateHosts: allowPrivate, validateResolvedIP: validateResolvedIP, maxResponseBytes: maxResponseBytes, - probeURLs: probeTargets, + configuredProbeURLs: configuredTargets, } } @@ -60,26 +57,27 @@ const ( defaultProxyProbeResponseMaxBytes = int64(1024 * 1024) ) -// probeTarget 描述一个探测端点及其响应解析方式。 -type probeTarget struct { - url string - parser string // "ip-api" / "ipify" / "chatgpt-trace" -} - -// defaultProbeURLs 按优先级排列的默认探测 URL 列表。 +// probeURLs 按优先级排列的内置探测 URL 列表。 // 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选。 -// 可通过配置 security.proxy_probe.urls 覆盖。 -var defaultProbeURLs = []probeTarget{ +var probeURLs = []struct { + url string + parser string +}{ {"http://ip-api.com/json/?lang=zh-CN", "ip-api"}, {"http://api64.ipify.org?format=json", "ipify"}, } +type configuredProbeTarget struct { + url string + parser string +} + type proxyProbeService struct { - insecureSkipVerify bool - allowPrivateHosts bool - validateResolvedIP bool - maxResponseBytes int64 - probeURLs []probeTarget + insecureSkipVerify bool + allowPrivateHosts bool + validateResolvedIP bool + maxResponseBytes int64 + configuredProbeURLs []configuredProbeTarget } func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) { @@ -95,10 +93,17 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s } var lastErr error - probeURLs := s.probeURLs - if len(probeURLs) == 0 { - probeURLs = defaultProbeURLs + if len(s.configuredProbeURLs) > 0 { + for _, probe := range s.configuredProbeURLs { + exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) + if err == nil { + return exitInfo, latencyMs, nil + } + lastErr = err + } + return nil, 0, fmt.Errorf("all probe URLs failed, last error: %w", lastErr) } + for _, probe := range probeURLs { exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) if err == nil {