Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// ReadRequestBodyWithPrealloc reads request body with preallocated buffer based
|
||||
// on content length, transparently decoding any Content-Encoding the upstream
|
||||
// client used to compress the body (zstd, gzip, deflate).
|
||||
func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) {
|
||||
if req == nil || req.Body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
capHint := requestBodyReadInitCap
|
||||
if req.ContentLength > 0 {
|
||||
switch {
|
||||
case req.ContentLength < int64(requestBodyReadInitCap):
|
||||
capHint = requestBodyReadInitCap
|
||||
case req.ContentLength > int64(requestBodyReadMaxInitCap):
|
||||
capHint = requestBodyReadMaxInitCap
|
||||
default:
|
||||
capHint = int(req.ContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(make([]byte, 0, capHint))
|
||||
if _, err := io.Copy(buf, req.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := buf.Bytes()
|
||||
|
||||
enc := strings.ToLower(strings.TrimSpace(req.Header.Get("Content-Encoding")))
|
||||
if enc == "" || enc == "identity" {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
decoded, err := decompressRequestBody(enc, raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode Content-Encoding %q: %w", enc, err)
|
||||
}
|
||||
|
||||
req.Header.Del("Content-Encoding")
|
||||
req.Header.Del("Content-Length")
|
||||
req.ContentLength = int64(len(decoded))
|
||||
|
||||
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":
|
||||
dec, err := zstd.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dec.Close()
|
||||
return io.ReadAll(io.LimitReader(dec, maxDecompressedBodySize))
|
||||
case "gzip", "x-gzip":
|
||||
gr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = gr.Close() }()
|
||||
return io.ReadAll(io.LimitReader(gr, maxDecompressedBodySize))
|
||||
case "deflate":
|
||||
zr, err := zlib.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = zr.Close() }()
|
||||
return io.ReadAll(io.LimitReader(zr, maxDecompressedBodySize))
|
||||
default:
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
const samplePayload = `{"model":"gpt-5.5","input":"hi","stream":false}`
|
||||
|
||||
func newRequestWithBody(t *testing.T, body []byte, encoding string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
if encoding != "" {
|
||||
req.Header.Set("Content-Encoding", encoding)
|
||||
}
|
||||
req.ContentLength = int64(len(body))
|
||||
return req
|
||||
}
|
||||
|
||||
func TestReadRequestBodyWithPrealloc_PassesThroughIdentity(t *testing.T) {
|
||||
req := newRequestWithBody(t, []byte(samplePayload), "")
|
||||
got, err := ReadRequestBodyWithPrealloc(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(got) != samplePayload {
|
||||
t.Fatalf("body mismatch: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestBodyWithPrealloc_DecodesZstd(t *testing.T) {
|
||||
enc, _ := zstd.NewWriter(nil)
|
||||
compressed := enc.EncodeAll([]byte(samplePayload), nil)
|
||||
_ = enc.Close()
|
||||
|
||||
req := newRequestWithBody(t, compressed, "zstd")
|
||||
got, err := ReadRequestBodyWithPrealloc(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(got) != samplePayload {
|
||||
t.Fatalf("body mismatch: got %q", got)
|
||||
}
|
||||
if req.Header.Get("Content-Encoding") != "" {
|
||||
t.Fatalf("Content-Encoding should be cleared after decoding")
|
||||
}
|
||||
if req.ContentLength != int64(len(samplePayload)) {
|
||||
t.Fatalf("ContentLength not updated: %d", req.ContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestBodyWithPrealloc_DecodesGzip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
if _, err := gw.Write([]byte(samplePayload)); err != nil {
|
||||
t.Fatalf("gzip write: %v", err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
t.Fatalf("gzip close: %v", err)
|
||||
}
|
||||
|
||||
req := newRequestWithBody(t, buf.Bytes(), "gzip")
|
||||
got, err := ReadRequestBodyWithPrealloc(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(got) != samplePayload {
|
||||
t.Fatalf("body mismatch: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestBodyWithPrealloc_DecodesDeflate(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zlib.NewWriter(&buf)
|
||||
if _, err := zw.Write([]byte(samplePayload)); err != nil {
|
||||
t.Fatalf("zlib write: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zlib close: %v", err)
|
||||
}
|
||||
|
||||
req := newRequestWithBody(t, buf.Bytes(), "deflate")
|
||||
got, err := ReadRequestBodyWithPrealloc(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(got) != samplePayload {
|
||||
t.Fatalf("body mismatch: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestBodyWithPrealloc_RejectsUnsupportedEncoding(t *testing.T) {
|
||||
req := newRequestWithBody(t, []byte(samplePayload), "br")
|
||||
_, err := ReadRequestBodyWithPrealloc(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported encoding, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "br") {
|
||||
t.Fatalf("error should mention encoding, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestBodyWithPrealloc_RejectsCorruptZstd(t *testing.T) {
|
||||
req := newRequestWithBody(t, []byte("not actually zstd"), "zstd")
|
||||
_, err := ReadRequestBodyWithPrealloc(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupt zstd body, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestBodyWithPrealloc_NilBody(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
got, err := ReadRequestBodyWithPrealloc(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil body, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestBodyWithPrealloc_RespectsIdentityEncoding(t *testing.T) {
|
||||
req := newRequestWithBody(t, []byte(samplePayload), "identity")
|
||||
got, err := ReadRequestBodyWithPrealloc(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(got) != samplePayload {
|
||||
t.Fatalf("body mismatch: got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user