From 017f6bbd5edffea0639ef3c84c0391161983f1f3 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 31 Jul 2026 16:11:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20=E6=94=B6=E7=B4=A7=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=20URL=20=E8=B7=AF=E5=BE=84=E7=89=87=E6=AE=B5=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 网关有若干位置会把客户端可控的字符串拼进上游请求的 URL path(Responses 子路径、Gemini 模型名)。此前这些字符串未经校验直接参与拼接,可能改变上游 请求的路径结构,使实际发出的请求与客户端意图不一致。 - 新增 internal/service/upstream_path_guard.go:路径片段闭集允许清单 (\w + `-` + `.`),拒绝空片段、纯点片段、超长片段与过深后缀 - /responses/*subpath 三条路由入口新增守卫,不可转发的子路径直接 404; service 层同时保证不产出不合规后缀,拼接函数再兜底一层 - Gemini AI Studio 原先 5 处重复的 URL 拼接收敛为唯一构造点 buildGeminiAIStudioModelActionURL(校验模型片段 + action 白名单) - Gemini native / GetModel handler 增加入口校验;ForwardAIStudioGET 逐片段校验 - Grok video 端点的 request_id 增加片段合规校验 合法子路径(/compact、/compact/detail、/{id}/cancel 形态)与既有模型名行为 不变,通配路由保留。 --- .../internal/handler/gemini_v1beta_handler.go | 12 ++ backend/internal/pkg/xai/oauth.go | 5 + backend/internal/server/routes/gateway.go | 27 ++- .../internal/server/routes/gateway_test.go | 27 +++ .../internal/service/account_test_service.go | 11 +- .../gemini_chat_completions_compat_service.go | 12 +- .../service/gemini_messages_compat_service.go | 31 ++-- .../internal/service/gemini_upstream_url.go | 51 ++++++ .../service/gemini_upstream_url_test.go | 62 +++++++ .../service/openai_gateway_request_body.go | 25 ++- .../internal/service/upstream_path_guard.go | 95 +++++++++++ .../service/upstream_path_guard_test.go | 160 ++++++++++++++++++ 12 files changed, 489 insertions(+), 29 deletions(-) create mode 100644 backend/internal/service/gemini_upstream_url.go create mode 100644 backend/internal/service/gemini_upstream_url_test.go create mode 100644 backend/internal/service/upstream_path_guard.go create mode 100644 backend/internal/service/upstream_path_guard_test.go diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 40470ac41..3c60a7404 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -98,6 +98,12 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { googleError(c, http.StatusBadRequest, "Missing model in URL") return } + // 模型名会被拼进上游 URL 的 path,先在入口校验片段合规性, + // 见 service/upstream_path_guard.go。 + if !service.IsSafeGeminiModelPathSegment(modelName) { + googleError(c, http.StatusBadRequest, "Invalid model in URL") + return + } if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" { modelName = strings.TrimSpace(resolvedModel) } @@ -169,6 +175,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { googleError(c, http.StatusNotFound, err.Error()) return } + // URL 里的模型名最终会被拼进上游 /v1beta/models/{model}:{action}, + // 先在入口校验片段合规性,见 service/upstream_path_guard.go。 + if !service.IsSafeGeminiModelPathSegment(modelName) { + googleError(c, http.StatusBadRequest, "Invalid model in URL") + return + } if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" { modelName = strings.TrimSpace(resolvedModel) } diff --git a/backend/internal/pkg/xai/oauth.go b/backend/internal/pkg/xai/oauth.go index 07d2227c2..afa44d06a 100644 --- a/backend/internal/pkg/xai/oauth.go +++ b/backend/internal/pkg/xai/oauth.go @@ -611,6 +611,11 @@ func BuildVideoURLWithValidator(baseURL, requestID string, validator BaseURLVali if requestID == "" { return "", fmt.Errorf("request id is required") } + // requestID 由客户端提供并拼进上游 URL 的 path。PathEscape 之外再要求它不是 + // 纯点片段、不含控制字符,保证它只能是一个普通的路径片段。 + if requestID == "." || requestID == ".." || strings.ContainsAny(requestID, "\x00\r\n") { + return "", fmt.Errorf("invalid request id") + } return validatedBaseURL + "/videos/" + url.PathEscape(requestID), nil } diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index b059646fa..1ce1fd978 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -153,6 +153,25 @@ func RegisterGatewayRoutes( service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Videos API is not supported for this platform"}}) } + // /responses/*subpath 的子路径会被转发到上游同名端点之后,因此在入口就拒掉 + // 不可转发的子路径,不让它进入调度与转发流程。可转发的判定见 + // service.IsForwardableOpenAIResponsesRequestPath 及 upstream_path_guard.go。 + guardResponsesSubpath := func(next gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + if !service.IsForwardableOpenAIResponsesRequestPath(c) { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "type": "not_found_error", + "message": "Unsupported responses subpath", + }, + }) + return + } + next(c) + } + } + // API网关(Claude API兼容) gateway := r.Group("/v1") gateway.Use(bodyLimit) @@ -190,13 +209,13 @@ func RegisterGatewayRoutes( } h.Gateway.Responses(c) }) - gateway.POST("/responses/*subpath", func(c *gin.Context) { + gateway.POST("/responses/*subpath", guardResponsesSubpath(func(c *gin.Context) { if isOpenAIResponsesCompatibleGatewayPlatform(c) { h.OpenAIGateway.Responses(c) return } h.Gateway.Responses(c) - }) + })) gateway.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch) gateway.GET("/responses", func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) @@ -269,7 +288,7 @@ func RegisterGatewayRoutes( h.Gateway.Responses(c) } r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler) - r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler) + r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, guardResponsesSubpath(responsesHandler)) r.POST("/alpha/search", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.OpenAIGateway.AlphaSearch) r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) @@ -282,7 +301,7 @@ func RegisterGatewayRoutes( codexDirect.POST("/realtime/calls", h.OpenAIGateway.Live) codexDirect.GET("/:call_id", h.OpenAIGateway.LiveSideband) codexDirect.POST("/responses", responsesHandler) - codexDirect.POST("/responses/*subpath", responsesHandler) + codexDirect.POST("/responses/*subpath", guardResponsesSubpath(responsesHandler)) codexDirect.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch) codexDirect.GET("/responses", func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) diff --git a/backend/internal/server/routes/gateway_test.go b/backend/internal/server/routes/gateway_test.go index 195351f9e..ada931e3a 100644 --- a/backend/internal/server/routes/gateway_test.go +++ b/backend/internal/server/routes/gateway_test.go @@ -333,6 +333,33 @@ func TestGatewayRoutesGrokAllowsCLICompatibilityEntrypoints(t *testing.T) { } } +// TestGatewayRoutesResponsesSubpathRejectsNonConformingSubpaths 端到端锁定不变式: +// /responses/*subpath 的子路径会被转发到上游同名端点之后,因此不合规的子路径必须 +// 在入口就被拒绝,不得进入调度与转发流程。 +func TestGatewayRoutesResponsesSubpathRejectsNonConformingSubpaths(t *testing.T) { + router := newGatewayRoutesTestRouter() + + for _, path := range []string{ + "/v1/responses/../../x/y", + "/v1/responses/..%2f..%2fx/y", + "/v1/responses/%2e%2e/%2e%2e/x", + "/responses/%2e%2e%2fx", + "/backend-api/codex/responses/..%2f..%2fx", + `/v1/responses/..\..\x`, + "/v1/responses/%3fa=b", + "/v1/responses/x%23frag", + "/v1/responses/compact%2f..", + } { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-5"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + require.Equal(t, http.StatusNotFound, w.Code, "path=%s must be rejected at the edge", path) + require.Contains(t, w.Body.String(), "Unsupported responses subpath", "path=%s", path) + } +} + func TestGatewayRoutesOpenAICountTokensPathIsRegistered(t *testing.T) { router := newGatewayRoutesTestRouter(service.PlatformOpenAI) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 9b530aa6e..134abe381 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -1209,8 +1209,10 @@ func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, accou } // Use streamGenerateContent for real-time feedback - fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", - strings.TrimRight(normalizedBaseURL, "/"), modelID) + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, modelID, "streamGenerateContent", true) + if err != nil { + return nil, err + } req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(payload)) if err != nil { @@ -1246,7 +1248,10 @@ func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, accoun if err != nil { return nil, err } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", strings.TrimRight(normalizedBaseURL, "/"), modelID) + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, modelID, "streamGenerateContent", true) + if err != nil { + return nil, err + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(payload)) if err != nil { diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index d766dc9ab..228f0153e 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -322,9 +322,9 @@ func (s *GeminiMessagesCompatService) buildGeminiChatCompletionsUpstreamRequestF if clientStream { action = "streamGenerateContent" } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, action) - if clientStream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, action, clientStream) + if err != nil { + return nil, "", err } restGeminiReq := normalizeGeminiRequestForAIStudio(geminiReq) @@ -389,9 +389,9 @@ func (s *GeminiMessagesCompatService) buildGeminiChatCompletionsUpstreamRequestF return nil, "", err } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, action) - if useUpstreamStream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, action, useUpstreamStream) + if err != nil { + return nil, "", err } restGeminiReq := normalizeGeminiRequestForAIStudio(geminiReq) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 1752e74bb..e4df8982d 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -638,9 +638,9 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex if req.Stream { action = "streamGenerateContent" } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, action) - if req.Stream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, action, req.Stream) + if err != nil { + return nil, "", err } restGeminiReq := normalizeGeminiRequestForAIStudio(geminiReq) @@ -712,9 +712,9 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex return nil, "", err } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, action) - if useUpstreamStream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, action, useUpstreamStream) + if err != nil { + return nil, "", err } restGeminiReq := normalizeGeminiRequestForAIStudio(geminiReq) @@ -1181,9 +1181,9 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, "", err } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, upstreamAction) - if useUpstreamStream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, upstreamAction, useUpstreamStream) + if err != nil { + return nil, "", err } upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(body)) @@ -1249,9 +1249,9 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, "", err } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, upstreamAction) - if useUpstreamStream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, upstreamAction, useUpstreamStream) + if err != nil { + return nil, "", err } upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(body)) @@ -2701,10 +2701,13 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac if account == nil { return nil, errors.New("account is nil") } - path = strings.TrimSpace(path) - if path == "" || !strings.HasPrefix(path, "/") { + // path 会被直接拼到上游 base URL 后面,因此按路径护栏逐片段校验, + // 见 upstream_path_guard.go。 + sanitizedPath, ok := sanitizedUpstreamPathSuffix(path) + if !ok || sanitizedPath == "" { return nil, errors.New("invalid path") } + path = sanitizedPath baseURL := account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) diff --git a/backend/internal/service/gemini_upstream_url.go b/backend/internal/service/gemini_upstream_url.go new file mode 100644 index 000000000..576b56b88 --- /dev/null +++ b/backend/internal/service/gemini_upstream_url.go @@ -0,0 +1,51 @@ +package service + +import ( + "errors" + "fmt" + "strings" +) + +// geminiAIStudioActions 是允许出现在上游 URL 里的 action 集合,与 ForwardNative +// 的入站校验保持一致,同时避免 compat 侧把可变字符串直接拼进 path。 +var geminiAIStudioActions = map[string]struct{}{ + "generateContent": {}, + "streamGenerateContent": {}, + "countTokens": {}, +} + +// buildGeminiAIStudioModelActionURL 组装 AI Studio 的 +// /v1beta/models/{model}:{action} 上游 URL。 +// +// model 是客户端可控的(native 路由取自 URL 片段,compat 路由取自请求体的 model +// 字段,之后可能再经渠道映射),因此必须先过路径片段护栏才能拼进 path, +// 见 upstream_path_guard.go。新增 AI Studio 端点请一律走本函数。 +func buildGeminiAIStudioModelActionURL(baseURL, model, action string, stream bool) (string, error) { + trimmedBase := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if trimmedBase == "" { + return "", errors.New("gemini base url is required") + } + trimmedModel := strings.TrimSpace(model) + if trimmedModel == "" { + return "", errors.New("gemini model is required") + } + if err := validateUpstreamPathSegment("gemini model", trimmedModel); err != nil { + return "", err + } + trimmedAction := strings.TrimSpace(action) + if _, ok := geminiAIStudioActions[trimmedAction]; !ok { + return "", fmt.Errorf("unsupported gemini action: %s", trimmedAction) + } + + fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", trimmedBase, trimmedModel, trimmedAction) + if stream { + fullURL += "?alt=sse" + } + return fullURL, nil +} + +// IsSafeGeminiModelPathSegment 供 handler 层在解析出 URL 里的模型名后立刻校验, +// 让客户端拿到明确的 400,而不是等到构造上游请求时才报错。 +func IsSafeGeminiModelPathSegment(model string) bool { + return isSafeUpstreamPathSegment(strings.TrimSpace(model)) +} diff --git a/backend/internal/service/gemini_upstream_url_test.go b/backend/internal/service/gemini_upstream_url_test.go new file mode 100644 index 000000000..04adbc1b0 --- /dev/null +++ b/backend/internal/service/gemini_upstream_url_test.go @@ -0,0 +1,62 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildGeminiAIStudioModelActionURL(t *testing.T) { + const base = "https://generativelanguage.googleapis.com" + + got, err := buildGeminiAIStudioModelActionURL(base, "gemini-2.5-pro", "generateContent", false) + require.NoError(t, err) + require.Equal(t, base+"/v1beta/models/gemini-2.5-pro:generateContent", got) + + got, err = buildGeminiAIStudioModelActionURL(base+"/", " gemini-2.5-flash ", "streamGenerateContent", true) + require.NoError(t, err) + require.Equal(t, base+"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", got) + + got, err = buildGeminiAIStudioModelActionURL(base, "gemini-2.5-pro", "countTokens", false) + require.NoError(t, err) + require.Equal(t, base+"/v1beta/models/gemini-2.5-pro:countTokens", got) +} + +// TestBuildGeminiAIStudioModelActionURLRejectsNonConformingModel 锁定不变式: +// 模型名来自客户端(native 路由的 URL 片段 / compat 路由的请求体), +// 只有合规的路径片段才允许拼进上游 URL。 +func TestBuildGeminiAIStudioModelActionURLRejectsNonConformingModel(t *testing.T) { + const base = "https://generativelanguage.googleapis.com" + + for _, model := range []string{ + "../../x/y", + "..", + ".", + "gemini-2.5-pro/../../x", + `..\..\x`, + "gemini-2.5-pro?a=b", + "gemini-2.5-pro#frag", + "gemini-2.5-pro%2f..", + "gemini 2.5 pro", + "gemini\x00pro", + "gemini-2.5-pro@001", + "gemini~pro", + "models/gemini-2.5-pro", + "...", + "", + " ", + } { + t.Run("model_"+model, func(t *testing.T) { + _, err := buildGeminiAIStudioModelActionURL(base, model, "generateContent", false) + require.Error(t, err, "model %q must be rejected", model) + require.False(t, IsSafeGeminiModelPathSegment(model)) + }) + } + + // action 只允许已知取值,避免未来把可变字符串拼进 path。 + _, err := buildGeminiAIStudioModelActionURL(base, "gemini-2.5-pro", "deleteModel", false) + require.Error(t, err) + + _, err = buildGeminiAIStudioModelActionURL("", "gemini-2.5-pro", "generateContent", false) + require.Error(t, err) +} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index fdd401911..5f23cd157 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -364,7 +364,27 @@ func resolveOpenAICompactSessionID(c *gin.Context) string { return uuid.NewString() } +// openAIResponsesRequestPathSuffix 返回可拼接到上游 /responses URL 后面的子路径。 +// 不可转发的子路径返回空串(退化为裸 /responses);真正的拒绝由入口守卫 +// IsForwardableOpenAIResponsesRequestPath 负责。这样即便将来新增路由漏挂守卫, +// 拼进上游 URL 的也只会是合规片段。 func openAIResponsesRequestPathSuffix(c *gin.Context) string { + suffix, ok := sanitizedUpstreamPathSuffix(rawOpenAIResponsesRequestPathSuffix(c)) + if !ok { + return "" + } + return suffix +} + +// IsForwardableOpenAIResponsesRequestPath 判断入站请求携带的 /responses 子路径 +// 是否可以安全转发。路由层用它在鉴权后、调度前直接拒绝畸形子路径。 +func IsForwardableOpenAIResponsesRequestPath(c *gin.Context) bool { + _, ok := sanitizedUpstreamPathSuffix(rawOpenAIResponsesRequestPathSuffix(c)) + return ok +} + +// rawOpenAIResponsesRequestPathSuffix 仅做提取,不做任何安全判断。 +func rawOpenAIResponsesRequestPathSuffix(c *gin.Context) string { if c == nil || c.Request == nil || c.Request.URL == nil { return "" } @@ -388,8 +408,9 @@ func openAIResponsesRequestPathSuffix(c *gin.Context) string { func appendOpenAIResponsesRequestPathSuffix(baseURL, suffix string) string { trimmedBase := strings.TrimRight(strings.TrimSpace(baseURL), "/") - trimmedSuffix := strings.TrimSpace(suffix) - if trimmedBase == "" || trimmedSuffix == "" { + // 兜底:调用方漏了校验时,这里也不会把不合规的片段拼进上游 URL。 + trimmedSuffix, ok := sanitizedUpstreamPathSuffix(suffix) + if !ok || trimmedBase == "" || trimmedSuffix == "" { return trimmedBase } return trimmedBase + trimmedSuffix diff --git a/backend/internal/service/upstream_path_guard.go b/backend/internal/service/upstream_path_guard.go new file mode 100644 index 000000000..e976ab9be --- /dev/null +++ b/backend/internal/service/upstream_path_guard.go @@ -0,0 +1,95 @@ +package service + +import ( + "fmt" + "strings" +) + +// 上游 URL 路径片段护栏。 +// +// 网关会把若干客户端可控的字符串(Responses 子路径、Gemini 模型名)拼进上游请求 +// 的 URL path。约定:这些字符串只允许"结构惰性"的字符,即拼进去之后不可能改变 +// 上游请求的路径结构;不符合的一律拒绝。 +// +// 实现上是**闭集允许清单(默认拒绝)**,请勿改成"逐个拒绝已知的坏字符":后者要求 +// 穷举,漏一项就失效;默认拒绝则相反,清单外的写法天然被挡住,无需跟着改代码。 +// +// 另外注意:到达业务代码的 c.Request.URL.Path 已经是百分号解码后的结果,因此校验 +// 必须放在这一层,不能假设上游看到的路径与客户端书写形式一致。 +// +// 这里只校验、不改写:把不合规输入自动修正成合规路径,会让上游收到与客户端意图 +// 不同的请求,也会掩盖调用方的错误。 + +const ( + // maxUpstreamPathSegmentLen 单个路径片段长度上限。真实的 response id、模型名 + // 都远短于此,留足余量只为拒绝异常输入。 + maxUpstreamPathSegmentLen = 128 + // maxUpstreamPathSegments 后缀允许的片段数上限(如 /{id}/cancel 为 2)。 + maxUpstreamPathSegments = 8 +) + +// isSafeUpstreamPathSegmentByte 是闭集允许清单:只放行 `\w`(即 [A-Za-z0-9_]) +// 以及真实取值必需的 `-` 与 `.`。其余字符(含控制字符与非 ASCII)一律拒绝。 +func isSafeUpstreamPathSegmentByte(b byte) bool { + switch { + case b >= 'a' && b <= 'z', b >= 'A' && b <= 'Z', b >= '0' && b <= '9': + return true + case b == '_', b == '-', b == '.': + return true + default: + return false + } +} + +// isSafeUpstreamPathSegment 判断 segment 能否原样拼进上游 URL 的一个 path 片段。 +// +// 允许清单里唯一在路径语义中有特殊含义的字符是 `.`,因此额外要求片段不能只由点 +// 组成——各类实现对这种片段的解释并不一致,直接拒绝最省心。 +func isSafeUpstreamPathSegment(segment string) bool { + if segment == "" || len(segment) > maxUpstreamPathSegmentLen { + return false + } + dotsOnly := true + for i := 0; i < len(segment); i++ { + if !isSafeUpstreamPathSegmentByte(segment[i]) { + return false + } + if segment[i] != '.' { + dotsOnly = false + } + } + return !dotsOnly +} + +// sanitizedUpstreamPathSuffix 校验 "/a/b" 形态的路径后缀。 +// ok=false 表示后缀不可转发,调用方必须拒绝请求,而不是降级成空后缀——否则 +// /responses/compact 之类的请求语义会被静默改写。空后缀合法,表示"没有子路径"。 +func sanitizedUpstreamPathSuffix(raw string) (string, bool) { + suffix := strings.TrimSpace(raw) + if suffix == "" { + return "", true + } + if !strings.HasPrefix(suffix, "/") { + return "", false + } + segments := strings.Split(strings.TrimPrefix(suffix, "/"), "/") + if len(segments) > maxUpstreamPathSegments { + return "", false + } + for _, segment := range segments { + if !isSafeUpstreamPathSegment(segment) { + return "", false + } + } + return suffix, true +} + +// validateUpstreamPathSegment 供 URL 构造点使用:不合规的片段直接变成显式错误, +// 不再继续构造与发出上游请求。 +func validateUpstreamPathSegment(kind, segment string) error { + if isSafeUpstreamPathSegment(strings.TrimSpace(segment)) { + return nil + } + // 不回显原始输入,避免把它写进日志与错误响应。 + return fmt.Errorf("invalid %s for upstream url path", kind) +} diff --git a/backend/internal/service/upstream_path_guard_test.go b/backend/internal/service/upstream_path_guard_test.go new file mode 100644 index 000000000..3ea88da28 --- /dev/null +++ b/backend/internal/service/upstream_path_guard_test.go @@ -0,0 +1,160 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSanitizedUpstreamPathSuffixRejectsNonConformingSegments(t *testing.T) { + // 到达业务代码的 URL.Path 已是百分号解码后的结果,因此用例按解码后的形态书写。 + rejected := []string{ + "/..", + "/../..", + "/../../x/y", + "/./compact", + "/compact/..", + `/..\..\x`, + `/compact\..`, + "/?a=b", + "/compact?a=b", + "/compact#frag", + "/compact%2f..", + "/100%", + "//double", + "/compact//detail", + "/compact/", + "/ compact", + "/compact\x00", + "/compact\nX-Injected: 1", + "/模型", + "compact", + "/a:b", + "/a;b", + "/a,b", + "/a=b", + "/a&b", + // 允许清单是闭集:`\w` + `-` + `.` 以外的字符一律拒绝, + // 不依赖任何"已知坏字符"清单。 + "/a~b", + "/a@b", + "/a+b", + "/a|b", + "/a*b", + "/a$b", + "/a(b)", + "/a'b", + "/a\"b", + "/a