diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index aae99a2a2..fced35e71 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -189,7 +189,11 @@ func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChan } channel.ModelsJSON = string(modelsJSON) channel.HeadersJSON = headersJSON - channel.ProxyURL = strings.TrimSpace(req.ProxyURL) + proxyURL := strings.TrimSpace(req.ProxyURL) + if _, err := ValidateProxyURL(proxyURL); err != nil { + return channel, err + } + channel.ProxyURL = proxyURL if req.Enabled != nil { channel.Enabled = *req.Enabled } diff --git a/backend/internal/app/admin_channel_test.go b/backend/internal/app/admin_channel_test.go index 7712e658b..d16525f1c 100644 --- a/backend/internal/app/admin_channel_test.go +++ b/backend/internal/app/admin_channel_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "testing" "yingce/backend/internal/model" @@ -189,6 +190,24 @@ func TestChannelFromRequestStoresAndClearsHeaders(t *testing.T) { } } +func TestChannelFromRequestValidatesProxyURL(t *testing.T) { + limit := 4 + base := model.ModelChannel{} + channel, err := channelFromRequest(ChannelRequest{Name: "Proxy", BaseURL: "https://93.184.216.34/v1", ProxyURL: "socks5://user:secret@127.0.0.1:1080", ConcurrencyLimit: &limit}, base) + if err != nil { + t.Fatal(err) + } + if channel.ProxyURL != "socks5://user:secret@127.0.0.1:1080" { + t.Fatalf("proxy URL = %q", channel.ProxyURL) + } + for _, proxyURL := range []string{"ftp://127.0.0.1:1080", "socks5://", "socks5://127.0.0.1:1080#fragment", "http://" + strings.Repeat("a", 506)} { + _, err := channelFromRequest(ChannelRequest{Name: "Proxy", BaseURL: "https://93.184.216.34/v1", ProxyURL: proxyURL, ConcurrencyLimit: &limit}, base) + if err == nil { + t.Fatalf("invalid proxy URL %q was accepted", proxyURL) + } + } +} + func TestPublicChannelOnlyReturnsSystemHeadersAndProxyToAdmin(t *testing.T) { channel := model.ModelChannel{ID: "system-1", Scope: model.ChannelScopeSystem, BaseURL: "https://example.com/v1", HeadersJSON: `[{"name":"X-Gateway-Tenant","value":"tenant-a"}]`, ProxyURL: "socks5://user:secret@127.0.0.1:1080"} adminView := publicChannel(channel, true, nil) diff --git a/backend/internal/app/channel_outbound.go b/backend/internal/app/channel_outbound.go index 9753dd734..aeec51acd 100644 --- a/backend/internal/app/channel_outbound.go +++ b/backend/internal/app/channel_outbound.go @@ -13,7 +13,7 @@ func (s *Service) ValidateChannelOutboundURL(rawURL string) (*url.URL, error) { } // OutboundHTTPClientForChannel 返回渠道专用的出站 HTTP 客户端。 -// proxyURL 非空时使用渠道级代理(socks5/http/https),否则回退到全局客户端。 +// proxyURL 非空时使用渠道级代理(socks5/socks5h/http/https),否则回退到全局客户端。 func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, _ *url.URL, proxyURL string) *http.Client { return OutboundHTTPClientWithProxy(timeout, proxyURL) } diff --git a/backend/internal/app/outbound_alias.go b/backend/internal/app/outbound_alias.go index be699048b..a221f9d91 100644 --- a/backend/internal/app/outbound_alias.go +++ b/backend/internal/app/outbound_alias.go @@ -26,6 +26,11 @@ func ValidateCustomRelayURL(rawURL string) (*url.URL, error) { return parsed, mapOutboundError(err) } +func ValidateProxyURL(rawURL string) (*url.URL, error) { + parsed, err := outbound.ValidateProxyURL(rawURL) + return parsed, mapOutboundError(err) +} + func OutboundHTTPClient(timeout time.Duration) *http.Client { return outbound.OutboundHTTPClient(timeout) } diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index 4c5f66f50..d34034070 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -20,10 +20,10 @@ type ModelChannel struct { SecretKey string `json:"-"` APIFormat string `json:"apiFormat" gorm:"size:24"` ConcurrencyLimit int `json:"concurrencyLimit"` - // ProxyURL 是渠道级出站代理地址,支持 socks5://、http://、https://。 + // ProxyURL 是渠道级出站代理地址,支持 socks5://、socks5h://、http://、https://。 // 留空时使用全局环境变量代理。 - ProxyURL string `json:"proxyUrl" gorm:"size:512"` - ModelsJSON string `json:"modelsJson" gorm:"type:text"` + ProxyURL string `json:"proxyUrl" gorm:"size:512"` + ModelsJSON string `json:"modelsJson" gorm:"type:text"` // RetiredModelsJSON 记录已被一个模型家族吸收的上游 SKU,防止目录拉取时重新创建重复记录。 RetiredModelsJSON string `json:"-" gorm:"type:text"` HeadersJSON string `json:"-" gorm:"type:text"` diff --git a/backend/internal/outbound/outbound.go b/backend/internal/outbound/outbound.go index 9f11f95c6..0dbe749dc 100644 --- a/backend/internal/outbound/outbound.go +++ b/backend/internal/outbound/outbound.go @@ -24,6 +24,7 @@ const ( maxOutboundRedirects = 5 maxOutboundHeaderCount = 32 maxOutboundHeaderBytes = 16 << 10 + maxProxyURLBytes = 512 CustomRelayHeadersHeader = "X-Canvas-Upstream-Headers" DefaultOutboundUserAgent = "CanvasViewport/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)" ) @@ -96,6 +97,30 @@ func ValidateCustomRelayURL(rawURL string) (*url.URL, error) { return parsed, nil } +// ValidateProxyURL 校验渠道级代理地址。代理可以使用认证信息和私网地址, +// 因为它是管理员配置的出口,而不是用户可控的上游 URL。 +func ValidateProxyURL(rawURL string) (*url.URL, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return nil, nil + } + if len(rawURL) > maxProxyURLBytes { + return nil, BadAuthRequest("渠道代理地址过长") + } + parsed, err := url.Parse(rawURL) + if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" { + return nil, BadAuthRequest("渠道代理地址无效") + } + scheme := strings.ToLower(parsed.Scheme) + if scheme != "socks5" && scheme != "socks5h" && scheme != "http" && scheme != "https" { + return nil, BadAuthRequest("渠道代理只支持 socks5/socks5h/http/https") + } + if parsed.Fragment != "" { + return nil, BadAuthRequest("渠道代理地址不允许包含片段") + } + return parsed, nil +} + func OutboundHTTPClient(timeout time.Duration) *http.Client { return &http.Client{ Transport: outboundTransport, @@ -119,7 +144,7 @@ func OutboundHTTPClientWithProxy(timeout time.Duration, proxyURL string) *http.C } transport := newOutboundTransportWithProxy(proxyURL) if transport == nil { - return OutboundHTTPClient(timeout) + return &http.Client{Transport: rejectingTransport{err: errors.New("渠道代理地址无效")}, Timeout: timeout} } return &http.Client{ Transport: transport, @@ -134,6 +159,12 @@ func OutboundHTTPClientWithProxy(timeout time.Duration, proxyURL string) *http.C } } +type rejectingTransport struct{ err error } + +func (t rejectingTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, t.err +} + func CustomRelayHTTPClient(timeout time.Duration) *http.Client { return &http.Client{ Transport: customRelayTransport, @@ -315,8 +346,8 @@ func outboundProxyFromEnvironment(req *http.Request) (*url.URL, error) { // 本地 DNS 在 TUN/VPN 模式下会返回 fake-ip,若先本地解析再经代 // 理连接会导致远端拿到不可达的假 IP;交给远端解析可避免此问题。 func newOutboundTransportWithProxy(proxyURL string) *http.Transport { - parsed, err := url.Parse(proxyURL) - if err != nil || parsed.Host == "" { + parsed, err := ValidateProxyURL(proxyURL) + if err != nil || parsed == nil { return nil } scheme := strings.ToLower(parsed.Scheme) diff --git a/backend/internal/outbound/outbound_test.go b/backend/internal/outbound/outbound_test.go index 41a7c0f5f..e856c5252 100644 --- a/backend/internal/outbound/outbound_test.go +++ b/backend/internal/outbound/outbound_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" ) @@ -46,6 +47,24 @@ func TestConfiguredProxyHostIsTrustedAsDeploymentEgress(t *testing.T) { } } +func TestOutboundHTTPClientWithProxyFailsClosedForInvalidURL(t *testing.T) { + client := OutboundHTTPClientWithProxy(time.Second, "ftp://127.0.0.1:1080") + request, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatal(err) + } + if _, err := client.Do(request); err == nil || !strings.Contains(err.Error(), "渠道代理地址无效") { + t.Fatalf("invalid proxy should fail closed, got %v", err) + } +} + +func TestOutboundHTTPClientWithProxyFallsBackForEmptyURL(t *testing.T) { + client := OutboundHTTPClientWithProxy(time.Second, " \t") + if client.Transport != outboundTransport { + t.Fatal("empty proxy URL should use the global outbound transport") + } +} + func TestNormalizeOutboundHeadersAllowsCustomUserAgent(t *testing.T) { headers, err := NormalizeOutboundHeaders([]OutboundHeader{{Name: "user-agent", Value: "Custom Gateway/2.0"}, {Name: "X-Gateway-Tenant", Value: "tenant-a"}}) if err != nil { diff --git a/backend/internal/service/aliases_funcs.go b/backend/internal/service/aliases_funcs.go index 7fe6f35a2..335f2e0fc 100644 --- a/backend/internal/service/aliases_funcs.go +++ b/backend/internal/service/aliases_funcs.go @@ -60,6 +60,7 @@ var ( ValidateCapabilitySpec = app.ValidateCapabilitySpec ValidateChannelModelPrice = app.ValidateChannelModelPrice ValidateCustomRelayURL = app.ValidateCustomRelayURL + ValidateProxyURL = app.ValidateProxyURL ValidateLogicalModelPrice = app.ValidateLogicalModelPrice ValidateOutboundHost = app.ValidateOutboundHost ValidateOutboundURL = app.ValidateOutboundURL diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx index d001ab62d..e2ec86ed0 100644 --- a/web/src/pages/admin/channels/channels-page.tsx +++ b/web/src/pages/admin/channels/channels-page.tsx @@ -399,7 +399,7 @@ export default function ChannelsPage() {