Skip to content

Commit 0d21bfa

Browse files
Merge pull request #418 from AikidoSec/parse-json-and-forms
2 parents 580b649 + 8e235b4 commit 0d21bfa

4 files changed

Lines changed: 155 additions & 15 deletions

File tree

instrumentation/http/body.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,25 @@ type MultipartFormParser interface {
1717
MultipartForm() (*multipart.Form, error)
1818
}
1919

20-
// TryExtractBody attempts to extract body data from a request, trying JSON first, then forms
20+
// TryExtractBody attempts to extract body data from a request using both JSON
21+
// and form parsers, returning whichever finds data. Both are always attempted
22+
// so the firewall does not depend on Content-Type to decide what the backend
23+
// will process.
2124
func TryExtractBody(req *http.Request, parser MultipartFormParser) any {
2225
if req.Body == nil || req.Body == http.NoBody {
2326
return nil
2427
}
2528

2629
bodyFromJSON := tryExtractJSON(req)
30+
bodyFromForm := tryExtractFormBody(req, parser)
31+
32+
if bodyFromJSON != nil && bodyFromForm != nil {
33+
return []any{bodyFromJSON, bodyFromForm}
34+
}
2735
if bodyFromJSON != nil {
2836
return bodyFromJSON
2937
}
30-
31-
bodyFromForm := tryExtractFormBody(req, parser)
32-
if bodyFromForm != nil {
33-
return bodyFromForm
34-
}
35-
36-
// No usable data found, returning nil
37-
return nil
38+
return bodyFromForm
3839
}
3940

4041
// tryExtractFormBody attempts to extract form data (urlencoded or multipart)

instrumentation/http/body_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,88 @@ func TestTryExtractBody(t *testing.T) {
139139
})
140140
}
141141

142+
func TestTryExtractBodyBypassVectors(t *testing.T) {
143+
t.Run("empty JSON object prefix does not suppress multipart field extraction", func(t *testing.T) {
144+
body := &bytes.Buffer{}
145+
writer := multipart.NewWriter(body)
146+
_ = writer.WriteField("name", "injected")
147+
writer.Close()
148+
149+
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader("{}\n"+body.String()))
150+
req.Header.Set("Content-Type", writer.FormDataContentType())
151+
152+
parser := &mockParser{req: req}
153+
result := TryExtractBody(req, parser)
154+
155+
formValues, ok := result.(url.Values)
156+
require.True(t, ok, "expected url.Values for multipart/form-data request, got %T: %v", result, result)
157+
assert.Equal(t, "injected", formValues.Get("name"))
158+
})
159+
160+
t.Run("non-empty JSON object prefix does not suppress multipart field extraction", func(t *testing.T) {
161+
body := &bytes.Buffer{}
162+
writer := multipart.NewWriter(body)
163+
_ = writer.WriteField("name", "injected")
164+
writer.Close()
165+
166+
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"key":"val"}`+"\n"+body.String()))
167+
req.Header.Set("Content-Type", writer.FormDataContentType())
168+
169+
parser := &mockParser{req: req}
170+
result := TryExtractBody(req, parser)
171+
172+
formValues, ok := result.(url.Values)
173+
require.True(t, ok, "expected url.Values for multipart/form-data request, got %T: %v", result, result)
174+
assert.Equal(t, "injected", formValues.Get("name"))
175+
})
176+
177+
t.Run("empty JSON array prefix does not suppress multipart field extraction", func(t *testing.T) {
178+
body := &bytes.Buffer{}
179+
writer := multipart.NewWriter(body)
180+
_ = writer.WriteField("name", "injected")
181+
writer.Close()
182+
183+
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader("[]\n"+body.String()))
184+
req.Header.Set("Content-Type", writer.FormDataContentType())
185+
186+
parser := &mockParser{req: req}
187+
result := TryExtractBody(req, parser)
188+
189+
formValues, ok := result.(url.Values)
190+
require.True(t, ok, "expected url.Values for multipart/form-data request, got %T: %v", result, result)
191+
assert.Equal(t, "injected", formValues.Get("name"))
192+
})
193+
194+
t.Run("JSON body is scanned even when Content-Type is multipart", func(t *testing.T) {
195+
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(`{"name":"injected"}`))
196+
req.Header.Set("Content-Type", "multipart/form-data; boundary=----boundary")
197+
198+
parser := &mockParser{req: req}
199+
result := TryExtractBody(req, parser)
200+
201+
resultMap, ok := result.(map[string]interface{})
202+
require.True(t, ok, "expected map, got %T: %v", result, result)
203+
assert.Equal(t, "injected", resultMap["name"])
204+
})
205+
206+
t.Run("NDJSON body returns all objects for inspection", func(t *testing.T) {
207+
body := `{"payload":"safe"}` + "\n" + `{"payload":"danger"}`
208+
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(body))
209+
req.Header.Set("Content-Type", "application/json")
210+
211+
parser := &mockParser{req: req}
212+
result := TryExtractBody(req, parser)
213+
214+
resultSlice, ok := result.([]interface{})
215+
require.True(t, ok, "expected []interface{} for NDJSON body, got %T: %v", result, result)
216+
require.Len(t, resultSlice, 2)
217+
218+
second, ok := resultSlice[1].(map[string]interface{})
219+
require.True(t, ok, "expected second element to be a map")
220+
assert.Equal(t, "danger", second["payload"])
221+
})
222+
}
223+
142224
func TestBodyStillReadableAfterExtraction(t *testing.T) {
143225
t.Run("body readable after form extraction", func(t *testing.T) {
144226
formData := url.Values{}

instrumentation/http/json.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package http
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
67
"io"
78
"net/http"
89
)
@@ -11,18 +12,33 @@ func tryExtractJSON(r *http.Request) any {
1112
var buf bytes.Buffer
1213
tee := io.TeeReader(r.Body, &buf)
1314

14-
var data any
15-
err := json.NewDecoder(tee).Decode(&data)
15+
decoder := json.NewDecoder(tee)
16+
var results []any
17+
for {
18+
var data any
19+
err := decoder.Decode(&data)
20+
if errors.Is(err, io.EOF) {
21+
break
22+
}
23+
if err != nil {
24+
_, _ = io.Copy(io.Discard, tee)
25+
r.Body = io.NopCloser(&buf)
26+
return nil
27+
}
28+
results = append(results, data)
29+
}
1630

1731
// Drain any remaining bytes to ensure full body is available in request
1832
// Ignore error - we still need to restore the request body
1933
_, _ = io.Copy(io.Discard, tee)
20-
2134
r.Body = io.NopCloser(&buf)
2235

23-
if err != nil {
36+
switch len(results) {
37+
case 0:
2438
return nil
39+
case 1:
40+
return results[0]
41+
default:
42+
return results
2543
}
26-
27-
return data
2844
}

instrumentation/http/json_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,49 @@ import (
55
"net/http/httptest"
66
"strings"
77
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
811
)
912

13+
func TestTryExtractJSONStreamingBehavior(t *testing.T) {
14+
t.Run("returns all objects when body contains multiple JSON objects", func(t *testing.T) {
15+
body := `{"first":true}` + "\n" + `{"second":true}`
16+
r := httptest.NewRequest("POST", "/test", strings.NewReader(body))
17+
18+
got := tryExtractJSON(r)
19+
20+
gotSlice, ok := got.([]interface{})
21+
require.True(t, ok, "expected []interface{}, got %T: %v", got, got)
22+
require.Len(t, gotSlice, 2)
23+
assert.Equal(t, true, gotSlice[0].(map[string]interface{})["first"])
24+
assert.Equal(t, true, gotSlice[1].(map[string]interface{})["second"])
25+
26+
restoredBody, _ := io.ReadAll(r.Body)
27+
assert.Equal(t, body, string(restoredBody))
28+
})
29+
30+
t.Run("returns nil when valid JSON is followed by non-JSON content", func(t *testing.T) {
31+
multipartTrailer := "\n------boundary\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nvalue\r\n------boundary--"
32+
body := "{}" + multipartTrailer
33+
r := httptest.NewRequest("POST", "/test", strings.NewReader(body))
34+
35+
got := tryExtractJSON(r)
36+
37+
assert.Nil(t, got)
38+
})
39+
40+
t.Run("returns nil when valid JSON array is followed by non-JSON content", func(t *testing.T) {
41+
multipartTrailer := "\n------boundary\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nvalue\r\n------boundary--"
42+
body := "[]" + multipartTrailer
43+
r := httptest.NewRequest("POST", "/test", strings.NewReader(body))
44+
45+
got := tryExtractJSON(r)
46+
47+
assert.Nil(t, got)
48+
})
49+
}
50+
1051
func TestTryExtractJSON(t *testing.T) {
1152
t.Run("good", func(t *testing.T) {
1253
body := `{"key": "value"}`

0 commit comments

Comments
 (0)