mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3843 from InCerryGit/fix/issue-3540-lenient-json-limit
fix(gateway): cap lenient JSON normalization
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user