body_lenient_json_test.go 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. package httputil
  2. import (
  3. "bytes"
  4. "errors"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "github.com/tidwall/gjson"
  9. )
  10. func TestNormalizeLenientJSONRequestBody_accepts_client_control_chars_in_strings(t *testing.T) {
  11. tests := []struct {
  12. name string
  13. body []byte
  14. path string
  15. want string
  16. wantRaw string
  17. }{
  18. {
  19. name: "null byte in message content",
  20. body: []byte("{\"messages\":[{\"content\":\"hello\x00world\"}]}"),
  21. path: "messages.0.content",
  22. want: "hello\x00world",
  23. wantRaw: `"hello\u0000world"`,
  24. },
  25. {
  26. name: "ansi escape in message content",
  27. body: []byte("{\"messages\":[{\"content\":\"hello\x1b[31mred\x1b[0m\"}]}"),
  28. path: "messages.0.content",
  29. want: "hello\x1b[31mred\x1b[0m",
  30. wantRaw: `"hello\u001b[31mred\u001b[0m"`,
  31. },
  32. {
  33. name: "leading UTF-8 BOM",
  34. body: []byte("\xef\xbb\xbf{\"input\":\"hello\"}"),
  35. path: "input",
  36. want: "hello",
  37. wantRaw: `"hello"`,
  38. },
  39. }
  40. for _, tt := range tests {
  41. t.Run(tt.name, func(t *testing.T) {
  42. // Given
  43. if gjson.ValidBytes(tt.body) {
  44. t.Fatalf("test payload should reproduce strict JSON rejection: %q", tt.body)
  45. }
  46. // When
  47. got, err := NormalizeLenientJSONRequestBody(tt.body, 1024)
  48. if err != nil {
  49. t.Fatalf("NormalizeLenientJSONRequestBody: %v", err)
  50. }
  51. // Then
  52. if !gjson.ValidBytes(got) {
  53. t.Fatalf("normalized body should be valid JSON: %q", got)
  54. }
  55. result := gjson.GetBytes(got, tt.path)
  56. if result.String() != tt.want {
  57. t.Fatalf("value mismatch: got %q want %q", result.String(), tt.want)
  58. }
  59. if result.Raw != tt.wantRaw {
  60. t.Fatalf("raw value mismatch: got %q want %q", result.Raw, tt.wantRaw)
  61. }
  62. })
  63. }
  64. }
  65. func TestNormalizeLenientJSONRequestBody_keeps_invalid_structure_invalid(t *testing.T) {
  66. tests := []struct {
  67. name string
  68. body []byte
  69. }{
  70. {
  71. name: "truncated JSON",
  72. body: []byte("{\"messages\":[{\"content\":\"hello\"}]"),
  73. },
  74. {
  75. name: "control character outside string",
  76. body: []byte("{\"input\":\"hello\"}\x00"),
  77. },
  78. }
  79. for _, tt := range tests {
  80. t.Run(tt.name, func(t *testing.T) {
  81. // When
  82. got, err := NormalizeLenientJSONRequestBody(tt.body, 1024)
  83. if err != nil {
  84. t.Fatalf("NormalizeLenientJSONRequestBody: %v", err)
  85. }
  86. // Then
  87. if gjson.ValidBytes(got) {
  88. t.Fatalf("normalization must not repair invalid JSON structure: %q", got)
  89. }
  90. })
  91. }
  92. }
  93. func TestNormalizeLenientJSONRequestBody_allows_http_requests_with_client_control_chars(t *testing.T) {
  94. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  95. // Given
  96. body, err := ReadLenientJSONRequestBodyWithPrealloc(r, 1024)
  97. if err != nil {
  98. http.Error(w, err.Error(), http.StatusBadRequest)
  99. return
  100. }
  101. // When
  102. if !gjson.ValidBytes(body) {
  103. http.Error(w, "Failed to parse request body", http.StatusBadRequest)
  104. return
  105. }
  106. w.WriteHeader(http.StatusAccepted)
  107. }))
  108. defer server.Close()
  109. tests := []struct {
  110. name string
  111. body []byte
  112. want int
  113. }{
  114. {
  115. name: "null byte in JSON string",
  116. body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\x00world\"}]}"),
  117. want: http.StatusAccepted,
  118. },
  119. {
  120. name: "ANSI escape in JSON string",
  121. body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\x1b[31mred\x1b[0m\"}]}"),
  122. want: http.StatusAccepted,
  123. },
  124. {
  125. name: "leading UTF-8 BOM",
  126. body: []byte("\xef\xbb\xbf{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}"),
  127. want: http.StatusAccepted,
  128. },
  129. {
  130. name: "truncated JSON",
  131. body: []byte("{\"model\":\"gpt-5.5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]"),
  132. want: http.StatusBadRequest,
  133. },
  134. }
  135. for _, tt := range tests {
  136. t.Run(tt.name, func(t *testing.T) {
  137. req, err := http.NewRequest(http.MethodPost, server.URL+"/v1/chat/completions", bytes.NewReader(tt.body))
  138. if err != nil {
  139. t.Fatalf("NewRequest: %v", err)
  140. }
  141. req.Header.Set("Content-Type", "application/json")
  142. resp, err := server.Client().Do(req)
  143. if err != nil {
  144. t.Fatalf("Do: %v", err)
  145. }
  146. defer func() { _ = resp.Body.Close() }()
  147. if resp.StatusCode != tt.want {
  148. t.Fatalf("status mismatch: got %d want %d", resp.StatusCode, tt.want)
  149. }
  150. })
  151. }
  152. }
  153. func TestNormalizeLenientJSONRequestBody_rejects_expansion_past_limit(t *testing.T) {
  154. // Given
  155. body := []byte("{\"input\":\"\x00\x00\"}")
  156. // When
  157. _, err := NormalizeLenientJSONRequestBody(body, int64(len(body)+5))
  158. // Then
  159. var maxErr *http.MaxBytesError
  160. if !errors.As(err, &maxErr) {
  161. t.Fatalf("expected MaxBytesError, got %T %v", err, err)
  162. }
  163. if maxErr.Limit != int64(len(body)+5) {
  164. t.Fatalf("limit mismatch: got %d want %d", maxErr.Limit, len(body)+5)
  165. }
  166. }