fix(gateway): 收紧上游 URL 路径片段校验

网关有若干位置会把客户端可控的字符串拼进上游请求的 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 形态)与既有模型名行为
不变,通配路由保留。
This commit is contained in:
shaw
2026-07-31 16:11:45 +08:00
parent f9d2791693
commit 017f6bbd5e
12 changed files with 489 additions and 29 deletions
@@ -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)
}
+5
View File
@@ -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
}
+23 -4
View File
@@ -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)
@@ -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)
@@ -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 {
@@ -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)
@@ -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)
@@ -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))
}
@@ -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)
}
@@ -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
@@ -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)
}
@@ -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<b",
"/a\tb",
"/a b",
"/a∕b", // DIVISION SLASH
"/a/b", // FULLWIDTH SOLIDUS
// 只由点组成的片段一律拒绝(各实现对其解释不一致)。
"/...",
"/....",
"/compact/...",
}
for _, suffix := range rejected {
t.Run("reject_"+suffix, func(t *testing.T) {
got, ok := sanitizedUpstreamPathSuffix(suffix)
require.False(t, ok, "suffix %q must be rejected", suffix)
require.Empty(t, got)
})
}
accepted := map[string]string{
"": "",
"/compact": "/compact",
"/compact/detail": "/compact/detail",
"/resp_68f0a1b2c3d4/cancel": "/resp_68f0a1b2c3d4/cancel",
"/gemini-2.5-pro_v1.2": "/gemini-2.5-pro_v1.2",
"/a.b.c": "/a.b.c",
}
for suffix, want := range accepted {
t.Run("accept_"+suffix, func(t *testing.T) {
got, ok := sanitizedUpstreamPathSuffix(suffix)
require.True(t, ok, "suffix %q must be accepted", suffix)
require.Equal(t, want, got)
})
}
}
func TestSanitizedUpstreamPathSuffixEnforcesBounds(t *testing.T) {
longSegment := "/"
for i := 0; i < maxUpstreamPathSegmentLen+1; i++ {
longSegment += "a"
}
_, ok := sanitizedUpstreamPathSuffix(longSegment)
require.False(t, ok, "over-long segment must be rejected")
deep := ""
for i := 0; i <= maxUpstreamPathSegments; i++ {
deep += "/a"
}
_, ok = sanitizedUpstreamPathSuffix(deep)
require.False(t, ok, "over-deep suffix must be rejected")
}
// TestOpenAIResponsesRequestPathSuffixRejectsNonConformingSubpaths 锁定不变式:
// /responses/*subpath 的子路径不得改变上游请求的路径结构;不合规时既不参与拼接,
// 也不会被误判成 compact 请求。
func TestOpenAIResponsesRequestPathSuffixRejectsNonConformingSubpaths(t *testing.T) {
gin.SetMode(gin.TestMode)
nonConformingPaths := []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/../../../x",
`/v1/responses/..\..\x`,
"/v1/responses/%3fa=b",
"/v1/responses/x%23frag",
"/v1/responses//double",
}
for _, path := range nonConformingPaths {
t.Run(path, func(t *testing.T) {
c := newResponsesSuffixTestContext(t, path)
require.False(t, IsForwardableOpenAIResponsesRequestPath(c),
"path %q must be rejected at the gateway edge", path)
require.Empty(t, openAIResponsesRequestPathSuffix(c),
"path %q must never contribute an upstream path suffix", path)
require.Equal(t, chatgptCodexURL,
appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, openAIResponsesRequestPathSuffix(c)))
require.False(t, isOpenAIResponsesCompactPath(c))
})
}
// 合法子路径必须保持原样转发。
for path, want := range map[string]string{
"/v1/responses": "",
"/v1/responses/compact": "/compact",
"/responses/compact/": "/compact",
"/backend-api/codex/responses/compact": "/compact",
} {
t.Run("forwardable_"+path, func(t *testing.T) {
c := newResponsesSuffixTestContext(t, path)
require.True(t, IsForwardableOpenAIResponsesRequestPath(c))
require.Equal(t, want, openAIResponsesRequestPathSuffix(c))
})
}
}
func TestAppendOpenAIResponsesRequestPathSuffixRefusesUnsafeSuffix(t *testing.T) {
// 调用方漏了校验时,拼接函数本身也不得把不合规片段带进上游 URL。
require.Equal(t, chatgptCodexURL, appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, "/../../x"))
require.Equal(t, chatgptCodexURL, appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, "/?a=b"))
require.Equal(t, chatgptCodexURL+"/compact", appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, "/compact"))
}
func newResponsesSuffixTestContext(t *testing.T, path string) *gin.Context {
t.Helper()
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, path, nil)
return c
}