mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-06 15:03:54 +08:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user