From 53a5c45bd86ace7f094cecc03aea9c129194a60d Mon Sep 17 00:00:00 2001 From: InCerry Date: Thu, 9 Jul 2026 11:15:52 +0800 Subject: [PATCH] fix(gateway): cap lenient json normalization Fixes #3540 --- backend/internal/handler/gateway_handler.go | 5 +- .../gateway_handler_chat_completions.go | 3 +- .../handler/gateway_handler_responses.go | 3 +- .../handler/openai_chat_completions.go | 3 +- .../handler/openai_gateway_count_tokens.go | 3 +- .../handler/openai_gateway_handler.go | 5 +- .../internal/handler/request_body_limit.go | 14 ++ backend/internal/pkg/httputil/body.go | 85 ++++++++ .../pkg/httputil/body_lenient_json_test.go | 184 ++++++++++++++++++ 9 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 backend/internal/pkg/httputil/body_lenient_json_test.go diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 0caa7f718b..8f863739ac 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -20,7 +20,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" pkgerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" @@ -138,7 +137,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { defer h.maybeLogCompatibilityFallbackMetrics(reqLog) // 读取请求体 - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) @@ -1777,7 +1776,7 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) { defer h.maybeLogCompatibilityFallbackMetrics(reqLog) // 读取请求体 - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index 03ceb0d952..f3805f3a53 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -7,7 +7,6 @@ import ( "strconv" "time" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -45,7 +44,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { ) // Read request body - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.chatCompletionsErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index f5ee18b722..5b49ca69a2 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -7,7 +7,6 @@ import ( "strconv" "time" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -45,7 +44,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) { ) // Read request body - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.responsesErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 847d386cde..f5f2522e49 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -7,7 +7,6 @@ import ( "strconv" "time" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" @@ -49,7 +48,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { return } - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go index 9a6709cc4f..0461017067 100644 --- a/backend/internal/handler/openai_gateway_count_tokens.go +++ b/backend/internal/handler/openai_gateway_count_tokens.go @@ -6,7 +6,6 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/domain" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -47,7 +46,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { return } - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 45eafbae76..f049711b7f 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -13,7 +13,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" - pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -185,7 +184,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } // Read request body - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) @@ -715,7 +714,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { return } - body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { if maxErr, ok := extractMaxBytesError(err); ok { h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) diff --git a/backend/internal/handler/request_body_limit.go b/backend/internal/handler/request_body_limit.go index d746673b34..de24551ba9 100644 --- a/backend/internal/handler/request_body_limit.go +++ b/backend/internal/handler/request_body_limit.go @@ -4,6 +4,9 @@ import ( "errors" "fmt" "net/http" + + "github.com/Wei-Shaw/sub2api/internal/config" + pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" ) func extractMaxBytesError(err error) (*http.MaxBytesError, bool) { @@ -25,3 +28,14 @@ func formatBodyLimit(limit int64) string { func buildBodyTooLargeMessage(limit int64) string { return fmt.Sprintf("Request body too large, limit is %s", formatBodyLimit(limit)) } + +func readLenientJSONRequestBodyWithPrealloc(req *http.Request, cfg *config.Config) ([]byte, error) { + return pkghttputil.ReadLenientJSONRequestBodyWithPrealloc(req, gatewayMaxBodySize(cfg)) +} + +func gatewayMaxBodySize(cfg *config.Config) int64 { + if cfg == nil { + return 0 + } + return cfg.Gateway.MaxBodySize +} diff --git a/backend/internal/pkg/httputil/body.go b/backend/internal/pkg/httputil/body.go index cee129484c..2bc3b9e753 100644 --- a/backend/internal/pkg/httputil/body.go +++ b/backend/internal/pkg/httputil/body.go @@ -16,6 +16,7 @@ import ( const ( requestBodyReadInitCap = 512 requestBodyReadMaxInitCap = 1 << 20 + jsonUTF8BOMLen = 3 // maxDecompressedBodySize limits the decompressed request body to 64 MB // to prevent decompression bomb attacks. maxDecompressedBodySize = 64 << 20 @@ -64,6 +65,16 @@ func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) { return decoded, nil } +// ReadLenientJSONRequestBodyWithPrealloc reads a request body and normalizes +// JSON string control bytes before strict validation. +func ReadLenientJSONRequestBodyWithPrealloc(req *http.Request, maxNormalizedBytes int64) ([]byte, error) { + body, err := ReadRequestBodyWithPrealloc(req) + if err != nil { + return nil, err + } + return NormalizeLenientJSONRequestBody(body, maxNormalizedBytes) +} + func decompressRequestBody(encoding string, raw []byte) ([]byte, error) { switch encoding { case "zstd": @@ -91,3 +102,77 @@ func decompressRequestBody(encoding string, raw []byte) ([]byte, error) { return nil, errors.New("unsupported Content-Encoding") } } + +// NormalizeLenientJSONRequestBody escapes raw control bytes that broken +// OpenAI-compatible clients sometimes place inside JSON strings. +func NormalizeLenientJSONRequestBody(body []byte, maxNormalizedBytes int64) ([]byte, error) { + if maxNormalizedBytes <= 0 { + maxNormalizedBytes = maxDecompressedBodySize + } + + body = trimUTF8BOM(body) + if len(body) == 0 { + return body, nil + } + if int64(len(body)) > maxNormalizedBytes { + return nil, &http.MaxBytesError{Limit: maxNormalizedBytes} + } + + var out []byte + inString := false + escaped := false + for i, b := range body { + if inString && isJSONControlByte(b) { + if out == nil { + capHint := len(body) + 6 + if int64(capHint) > maxNormalizedBytes { + capHint = int(maxNormalizedBytes) + } + out = make([]byte, 0, capHint) + out = append(out, body[:i]...) + } + if int64(len(out)+6) > maxNormalizedBytes { + return nil, &http.MaxBytesError{Limit: maxNormalizedBytes} + } + out = appendJSONUnicodeEscape(out, b) + escaped = false + continue + } + + switch { + case escaped: + escaped = false + case inString && b == '\\': + escaped = true + case b == '"': + inString = !inString + } + + if out != nil { + if int64(len(out)+1) > maxNormalizedBytes { + return nil, &http.MaxBytesError{Limit: maxNormalizedBytes} + } + out = append(out, b) + } + } + if out != nil { + return out, nil + } + return body, nil +} + +func trimUTF8BOM(body []byte) []byte { + if len(body) >= jsonUTF8BOMLen && body[0] == 0xef && body[1] == 0xbb && body[2] == 0xbf { + return body[jsonUTF8BOMLen:] + } + return body +} + +func isJSONControlByte(b byte) bool { + return b < 0x20 || b == 0x7f +} + +func appendJSONUnicodeEscape(dst []byte, b byte) []byte { + const hex = "0123456789abcdef" + return append(dst, '\\', 'u', '0', '0', hex[b>>4], hex[b&0x0f]) +} diff --git a/backend/internal/pkg/httputil/body_lenient_json_test.go b/backend/internal/pkg/httputil/body_lenient_json_test.go new file mode 100644 index 0000000000..71ffc392b8 --- /dev/null +++ b/backend/internal/pkg/httputil/body_lenient_json_test.go @@ -0,0 +1,184 @@ +package httputil + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/tidwall/gjson" +) + +func TestNormalizeLenientJSONRequestBody_accepts_client_control_chars_in_strings(t *testing.T) { + tests := []struct { + name string + body []byte + path string + want string + wantRaw string + }{ + { + name: "null byte in message content", + body: []byte("{\"messages\":[{\"content\":\"hello\x00world\"}]}"), + path: "messages.0.content", + want: "hello\x00world", + wantRaw: `"hello\u0000world"`, + }, + { + name: "ansi escape in message content", + body: []byte("{\"messages\":[{\"content\":\"hello\x1b[31mred\x1b[0m\"}]}"), + path: "messages.0.content", + want: "hello\x1b[31mred\x1b[0m", + wantRaw: `"hello\u001b[31mred\u001b[0m"`, + }, + { + name: "leading UTF-8 BOM", + body: []byte("\xef\xbb\xbf{\"input\":\"hello\"}"), + path: "input", + want: "hello", + wantRaw: `"hello"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Given + if gjson.ValidBytes(tt.body) { + t.Fatalf("test payload should reproduce strict JSON rejection: %q", tt.body) + } + + // When + got, err := NormalizeLenientJSONRequestBody(tt.body, 1024) + if err != nil { + t.Fatalf("NormalizeLenientJSONRequestBody: %v", err) + } + + // Then + if !gjson.ValidBytes(got) { + t.Fatalf("normalized body should be valid JSON: %q", got) + } + result := gjson.GetBytes(got, tt.path) + if result.String() != tt.want { + t.Fatalf("value mismatch: got %q want %q", result.String(), tt.want) + } + if result.Raw != tt.wantRaw { + t.Fatalf("raw value mismatch: got %q want %q", result.Raw, tt.wantRaw) + } + }) + } +} + +func TestNormalizeLenientJSONRequestBody_keeps_invalid_structure_invalid(t *testing.T) { + tests := []struct { + name string + body []byte + }{ + { + name: "truncated JSON", + body: []byte("{\"messages\":[{\"content\":\"hello\"}]"), + }, + { + name: "control character outside string", + body: []byte("{\"input\":\"hello\"}\x00"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // When + got, err := NormalizeLenientJSONRequestBody(tt.body, 1024) + if err != nil { + t.Fatalf("NormalizeLenientJSONRequestBody: %v", err) + } + + // Then + if gjson.ValidBytes(got) { + t.Fatalf("normalization must not repair invalid JSON structure: %q", got) + } + }) + } +} + +func TestNormalizeLenientJSONRequestBody_allows_http_requests_with_client_control_chars(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Given + body, err := ReadLenientJSONRequestBodyWithPrealloc(r, 1024) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // When + if !gjson.ValidBytes(body) { + http.Error(w, "Failed to parse request body", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + tests := []struct { + name string + body []byte + want int + }{ + { + name: "null byte in JSON string", + body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\x00world\"}]}"), + want: http.StatusAccepted, + }, + { + name: "ANSI escape in JSON string", + body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\x1b[31mred\x1b[0m\"}]}"), + want: http.StatusAccepted, + }, + { + name: "leading UTF-8 BOM", + body: []byte("\xef\xbb\xbf{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}"), + want: http.StatusAccepted, + }, + { + name: "truncated JSON", + body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]"), + want: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, server.URL+"/v1/chat/completions", bytes.NewReader(tt.body)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := server.Client().Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != tt.want { + t.Fatalf("status mismatch: got %d want %d", resp.StatusCode, tt.want) + } + }) + } +} + +func TestNormalizeLenientJSONRequestBody_rejects_expansion_past_limit(t *testing.T) { + // Given + body := []byte("{\"input\":\"\x00\x00\"}") + + // When + _, err := NormalizeLenientJSONRequestBody(body, int64(len(body)+5)) + + // Then + var maxErr *http.MaxBytesError + if !errors.As(err, &maxErr) { + t.Fatalf("expected MaxBytesError, got %T %v", err, err) + } + if maxErr.Limit != int64(len(body)+5) { + t.Fatalf("limit mismatch: got %d want %d", maxErr.Limit, len(body)+5) + } +}