diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index 404f17e5b8..a310288bf9 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -556,13 +556,21 @@ func postRawJSON(ctx context.Context, fullURL string, payload []byte, headers ma return respBody, resp.StatusCode, nil } -// joinURL 把 base origin 与 path 拼成完整 URL。 -// 容忍 base 末尾有/无斜杠,path 必带前导斜杠。 +// joinURL 保留 base 的上游路径前缀,并避免重复追加已有的 API 路径前缀。 +// 使用 EscapedPath 匹配完整路径段,避免把 hostname 或编码斜杠当作路径。 func joinURL(base, path string) string { base = strings.TrimRight(base, "/") if !strings.HasPrefix(path, "/") { path = "/" + path } + if u, err := url.Parse(base); err == nil { + basePath := u.EscapedPath() + for end := strings.LastIndex(path, "/"); end > 0; end = strings.LastIndex(path[:end], "/") { + if strings.HasSuffix(basePath, path[:end]) { + return base + path[end:] + } + } + } return base + path } diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index 155d6082f8..447581ddb2 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -185,7 +185,7 @@ var ( "CHANNEL_MONITOR_ENDPOINT_SCHEME", "endpoint must use https scheme", ) ErrChannelMonitorEndpointPath = infraerrors.BadRequest( - "CHANNEL_MONITOR_ENDPOINT_PATH", "endpoint must be base origin only (no path/query/fragment)", + "CHANNEL_MONITOR_ENDPOINT_PATH", "endpoint must not contain query parameters or a fragment", ) ErrChannelMonitorEndpointPrivate = infraerrors.BadRequest( "CHANNEL_MONITOR_ENDPOINT_PRIVATE", "endpoint must be a public host", diff --git a/backend/internal/service/channel_monitor_endpoint_test.go b/backend/internal/service/channel_monitor_endpoint_test.go new file mode 100644 index 0000000000..91482dd84f --- /dev/null +++ b/backend/internal/service/channel_monitor_endpoint_test.go @@ -0,0 +1,96 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateMonitorEndpoint_BasePath(t *testing.T) { + for _, endpoint := range []string{ + "https://8.8.8.8", + "https://8.8.8.8/anthropic", + "https://8.8.8.8/anthropic/", + "https://8.8.8.8/anthropic/v1", + "https://8.8.8.8/tenant%2Fone/anthropic", + } { + t.Run(endpoint, func(t *testing.T) { + require.NoError(t, validateEndpoint(endpoint)) + }) + } + + for _, tc := range []struct { + endpoint string + want error + }{ + {"http://8.8.8.8/anthropic", ErrChannelMonitorEndpointScheme}, + {"https://8.8.8.8/anthropic?key=secret", ErrChannelMonitorEndpointPath}, + {"https://8.8.8.8/anthropic#fragment", ErrChannelMonitorEndpointPath}, + {"https://127.0.0.1/anthropic", ErrChannelMonitorEndpointPrivate}, + {"https://10.0.0.1/anthropic", ErrChannelMonitorEndpointPrivate}, + {"https://169.254.169.254/anthropic", ErrChannelMonitorEndpointPrivate}, + {"https://[::1]/anthropic", ErrChannelMonitorEndpointPrivate}, + {"https://[fd00::1]/anthropic", ErrChannelMonitorEndpointPrivate}, + {"https:///anthropic", ErrChannelMonitorInvalidEndpoint}, + } { + t.Run(tc.endpoint, func(t *testing.T) { + require.ErrorIs(t, validateEndpoint(tc.endpoint), tc.want) + }) + } +} + +func TestJoinMonitorURL_DoesNotMatchHostOrEncodedSlash(t *testing.T) { + require.Equal(t, "https://v1/v1/messages", joinURL("https://v1", "/v1/messages")) + require.Equal(t, "https://relay.example/tenant%2Fv1/v1/messages", + joinURL("https://relay.example/tenant%2Fv1", "/v1/messages")) +} + +func TestNormalizeMonitorEndpoint_PreservesBasePath(t *testing.T) { + require.Equal(t, "https://api.deepseek.com/anthropic", + normalizeEndpoint(" https://api.deepseek.com/anthropic/ ")) + require.Equal(t, "https://relay.example/tenant%2Fone/anthropic/v1", + normalizeEndpoint("https://relay.example/tenant%2Fone/anthropic/v1/")) +} + +func TestCallProvider_BasePath(t *testing.T) { + swapMonitorHTTPClient(t) + requests := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- r.RequestURI + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{}")) + })) + t.Cleanup(server.Close) + + for _, tc := range []struct { + name string + provider string + apiMode string + basePath string + wantPath string + }{ + {"anthropic origin", MonitorProviderAnthropic, "", "", "/v1/messages"}, + {"anthropic prefix", MonitorProviderAnthropic, "", "/anthropic", "/anthropic/v1/messages"}, + {"anthropic trailing slash", MonitorProviderAnthropic, "", "/anthropic/", "/anthropic/v1/messages"}, + {"anthropic version", MonitorProviderAnthropic, "", "/anthropic/v1", "/anthropic/v1/messages"}, + {"version lookalike", MonitorProviderAnthropic, "", "/anthropic/v10", "/anthropic/v10/v1/messages"}, + {"encoded prefix", MonitorProviderAnthropic, "", "/tenant%2Fone/anthropic", "/tenant%2Fone/anthropic/v1/messages"}, + {"openai chat version", MonitorProviderOpenAI, "", "/relay/v1/", "/relay/v1/chat/completions"}, + {"openai responses version", MonitorProviderOpenAI, MonitorAPIModeResponses, "/relay/v1", "/relay/v1/responses"}, + {"gemini version", MonitorProviderGemini, "", "/relay/v1beta", "/relay/v1beta/models/test-model:generateContent"}, + {"zhipu version", MonitorProviderZhipu, "", "/api/paas/v4", "/api/paas/v4/chat/completions"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, status, err := callProvider(context.Background(), tc.provider, + server.URL+tc.basePath, "test-key", "test-model", "hello", &CheckOptions{APIMode: tc.apiMode}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status) + require.Equal(t, tc.wantPath, <-requests) + }) + } +} diff --git a/backend/internal/service/channel_monitor_validate.go b/backend/internal/service/channel_monitor_validate.go index 1ca6dcb701..085b777c0c 100644 --- a/backend/internal/service/channel_monitor_validate.go +++ b/backend/internal/service/channel_monitor_validate.go @@ -120,8 +120,7 @@ func validateJitter(jitterSec, intervalSec int) error { // validateEndpoint 校验 endpoint: // - scheme 强制 https(拒绝 http,避免明文凭证 + 部分 SSRF 利用面) -// - 必须为 origin(无 path/query/fragment),防止用户填 https://api.openai.com/v1 -// 导致 joinURL 拼出 /v1/v1/chat/completions +// - 允许上游路径前缀(如 /anthropic),不允许 query/fragment // - hostname 不能是 localhost/metadata 等已知元数据 hostname // - 解析所有 IP,任一落在 loopback/RFC1918/link-local/ULA 段即拒绝(防 SSRF) // @@ -141,9 +140,6 @@ func validateEndpoint(ep string) error { if u.Host == "" { return ErrChannelMonitorInvalidEndpoint } - if u.Path != "" && u.Path != "/" { - return ErrChannelMonitorEndpointPath - } if u.RawQuery != "" || u.Fragment != "" { return ErrChannelMonitorEndpointPath } @@ -161,8 +157,8 @@ func validateEndpoint(ep string) error { return nil } -// normalizeEndpoint 去除前后空白与末尾 `/`,保证存储统一为 origin。 -// validateEndpoint 已确保格式合法(仅 origin),这里只做最终归一化。 +// normalizeEndpoint 去除前后空白与末尾 `/`,保留上游路径前缀。 +// validateEndpoint 已确保格式合法,这里只做最终归一化。 func normalizeEndpoint(ep string) string { ep = strings.TrimSpace(ep) ep = strings.TrimRight(ep, "/") diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index b60f2bbcb2..fb75339248 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -129,7 +129,7 @@ export interface CreateParams { name: string provider: Provider api_mode?: APIMode - /** 探活模式必填(base origin);quota 模式可留空 */ + /** 探活模式必填(可含路径前缀的 HTTPS base URL);quota 模式可留空 */ endpoint: string /** 探活模式必填;quota 模式可留空 */ api_key: string