Skip to content

Commit 11e91b0

Browse files
philipaconradashutosh-narkar
authored andcommitted
util+server: Fix bug around chunked request handling. (#6906)
This commit fixes a request handling bug introduced in #6868, which caused OPA to treat all incoming chunked requests as if they had zero-length request bodies. The fix detects cases where the request body size is unknown in the DecodingLimits handler, and propagates a request context key down to the `util.ReadMaybeCompressedBody` function, allowing it to correctly select between using the original `io.ReadAll` style for chunked requests, or the newer preallocated buffers approach (for requests of known size). This change has a small, but barely visible performance impact for large requests (<5% increase in GC pauses for a 1GB request JSON blob), and minimal, if any, effect on RPS under load. Fixes: #6904 Signed-off-by: Philip Conrad <[email protected]> (cherry picked from commit ee9ab0b)
1 parent b62ae6b commit 11e91b0

File tree

5 files changed

+98
-14
lines changed

5 files changed

+98
-14
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
All notable changes to this project will be documented in this file. This
44
project adheres to [Semantic Versioning](http://semver.org/).
55

6+
## Unreleased
7+
8+
### Fixes
9+
10+
- util+server: Fix bug around chunked request handling. This PR fixes a request handling bug introduced in ([#6868](https://github.com/open-policy-agent/opa/pull/6868)), which caused OPA to treat all incoming chunked requests as if they had zero-length request bodies. ([#6906](https://github.com/open-policy-agent/opa/pull/6906)) authored by @philipaconrad
11+
612
## 0.67.0
713

814
This release contains a mix of features, a new builtin function (`strings.count`), performance improvements, and bugfixes.

server/handlers/decoding.go

+14-1
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,25 @@ import (
2020
func DecodingLimitsHandler(handler http.Handler, maxLength, gzipMaxLength int64) http.Handler {
2121
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2222
// Reject too-large requests before doing any further processing.
23-
// Note(philipc): This likely does nothing in the case of "chunked"
23+
// Note(philipc): This does nothing in the case of "chunked"
2424
// requests, since those should report a ContentLength of -1.
2525
if r.ContentLength > maxLength {
2626
writer.Error(w, http.StatusBadRequest, types.NewErrorV1(types.CodeInvalidParameter, types.MsgDecodingLimitError))
2727
return
2828
}
29+
// For requests where full size is not known in advance (such as chunked
30+
// requests), pass server.decoding.max_length down, using the request
31+
// context.
32+
33+
// Note(philipc): Unknown request body size is signaled to the server
34+
// handler by net/http setting the Request.ContentLength field to -1. We
35+
// don't check for the `Transfer-Encoding: chunked` header explicitly,
36+
// because net/http will strip it out from requests automatically.
37+
// Ref: https://pkg.go.dev/net/http#Request
38+
if r.ContentLength < 0 {
39+
ctx := util_decoding.AddServerDecodingMaxLen(r.Context(), maxLength)
40+
r = r.WithContext(ctx)
41+
}
2942
// Pass server.decoding.gzip.max_length down, using the request context.
3043
if strings.Contains(r.Header.Get("Content-Encoding"), "gzip") {
3144
ctx := util_decoding.AddServerDecodingGzipMaxLen(r.Context(), gzipMaxLength)

server/server_test.go

+50-6
Original file line numberDiff line numberDiff line change
@@ -1713,8 +1713,10 @@ func generateJSONBenchmarkData(k, v int) map[string]interface{} {
17131713
}
17141714

17151715
return map[string]interface{}{
1716-
"keys": keys,
1717-
"values": values,
1716+
"input": map[string]interface{}{
1717+
"keys": keys,
1718+
"values": values,
1719+
},
17181720
}
17191721
}
17201722

@@ -1832,10 +1834,12 @@ func TestDataPostV1CompressedDecodingLimits(t *testing.T) {
18321834
tests := []struct {
18331835
note string
18341836
wantGzip bool
1837+
wantChunkedEncoding bool
18351838
payload []byte
18361839
forceContentLen int64 // Size to manually set the Content-Length header to.
18371840
forcePayloadSizeField uint32 // Size to manually set the payload field for the gzip blob.
18381841
expRespHTTPStatus int
1842+
expWarningMsg string
18391843
expErrorMsg string
18401844
maxLen int64
18411845
gzipMaxLen int64
@@ -1844,30 +1848,44 @@ func TestDataPostV1CompressedDecodingLimits(t *testing.T) {
18441848
note: "empty message",
18451849
payload: []byte{},
18461850
expRespHTTPStatus: 200,
1851+
expWarningMsg: "'input' key missing from the request",
18471852
},
18481853
{
18491854
note: "empty message, gzip",
18501855
wantGzip: true,
18511856
payload: mustGZIPPayload([]byte{}),
18521857
expRespHTTPStatus: 200,
1858+
expWarningMsg: "'input' key missing from the request",
18531859
},
18541860
{
18551861
note: "empty message, malicious Content-Length",
18561862
payload: []byte{},
18571863
forceContentLen: 2048, // Server should ignore this header entirely.
1858-
expRespHTTPStatus: 200,
1864+
expRespHTTPStatus: 400,
1865+
expErrorMsg: "request body too large",
18591866
},
18601867
{
18611868
note: "empty message, gzip, malicious Content-Length",
18621869
wantGzip: true,
18631870
payload: mustGZIPPayload([]byte{}),
18641871
forceContentLen: 2048, // Server should ignore this header entirely.
1865-
expRespHTTPStatus: 200,
1872+
expRespHTTPStatus: 400,
1873+
expErrorMsg: "request body too large",
18661874
},
18671875
{
18681876
note: "basic - malicious size field, expect reject on gzip payload length",
18691877
wantGzip: true,
1870-
payload: mustGZIPPayload([]byte(`{"user": "alice"}`)),
1878+
payload: mustGZIPPayload([]byte(`{"input": {"user": "alice"}}`)),
1879+
expRespHTTPStatus: 400,
1880+
forcePayloadSizeField: 134217728, // 128 MB
1881+
expErrorMsg: "gzip payload too large",
1882+
gzipMaxLen: 1024,
1883+
},
1884+
{
1885+
note: "basic - malicious size field, expect reject on gzip payload length, chunked encoding",
1886+
wantGzip: true,
1887+
wantChunkedEncoding: true,
1888+
payload: mustGZIPPayload([]byte(`{"input": {"user": "alice"}}`)),
18711889
expRespHTTPStatus: 400,
18721890
forcePayloadSizeField: 134217728, // 128 MB
18731891
expErrorMsg: "gzip payload too large",
@@ -1886,6 +1904,13 @@ func TestDataPostV1CompressedDecodingLimits(t *testing.T) {
18861904
maxLen: 512,
18871905
expErrorMsg: "request body too large",
18881906
},
1907+
{
1908+
note: "basic, large payload, expect reject on Content-Length, chunked encoding",
1909+
wantChunkedEncoding: true,
1910+
payload: util.MustMarshalJSON(generateJSONBenchmarkData(100, 100)),
1911+
expRespHTTPStatus: 200,
1912+
maxLen: 134217728,
1913+
},
18891914
{
18901915
note: "basic, gzip, large payload",
18911916
wantGzip: true,
@@ -1957,13 +1982,19 @@ allow if {
19571982
if test.wantGzip {
19581983
req.Header.Set("Content-Encoding", "gzip")
19591984
}
1985+
if test.wantChunkedEncoding {
1986+
req.ContentLength = -1
1987+
req.TransferEncoding = []string{"chunked"}
1988+
req.Header.Set("Transfer-Encoding", "chunked")
1989+
}
19601990
if test.forceContentLen > 0 {
1991+
req.ContentLength = test.forceContentLen
19611992
req.Header.Set("Content-Length", strconv.FormatInt(test.forceContentLen, 10))
19621993
}
19631994
f.reset()
19641995
f.server.Handler.ServeHTTP(f.recorder, req)
19651996
if f.recorder.Code != test.expRespHTTPStatus {
1966-
t.Fatalf("Unexpected HTTP status code, (exp,got): %d, %d", test.expRespHTTPStatus, f.recorder.Code)
1997+
t.Fatalf("Unexpected HTTP status code, (exp,got): %d, %d, response body: %s", test.expRespHTTPStatus, f.recorder.Code, f.recorder.Body.Bytes())
19671998
}
19681999
if test.expErrorMsg != "" {
19692000
var serverErr types.ErrorV1
@@ -1973,6 +2004,19 @@ allow if {
19732004
if !strings.Contains(serverErr.Message, test.expErrorMsg) {
19742005
t.Fatalf("Expected error message to have message '%s', got message: '%s'", test.expErrorMsg, serverErr.Message)
19752006
}
2007+
} else {
2008+
var resp types.DataResponseV1
2009+
if err := json.Unmarshal(f.recorder.Body.Bytes(), &resp); err != nil {
2010+
t.Fatalf("Could not deserialize response: %s, message was: %s", err.Error(), f.recorder.Body.Bytes())
2011+
}
2012+
if test.expWarningMsg != "" {
2013+
if !strings.Contains(resp.Warning.Message, test.expWarningMsg) {
2014+
t.Fatalf("Expected warning message to have message '%s', got message: '%s'", test.expWarningMsg, resp.Warning.Message)
2015+
}
2016+
} else if resp.Warning != nil {
2017+
// Error on unexpected warnings. Something is wrong.
2018+
t.Fatalf("Unexpected warning: code: %s, message: %s", resp.Warning.Code, resp.Warning.Message)
2019+
}
19762020
}
19772021
})
19782022
}

util/decoding/context.go

+10
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,20 @@ const (
1111
reqCtxKeyGzipMaxLen = requestContextKey("server-decoding-plugin-context-gzip-max-length")
1212
)
1313

14+
func AddServerDecodingMaxLen(ctx context.Context, maxLen int64) context.Context {
15+
return context.WithValue(ctx, reqCtxKeyMaxLen, maxLen)
16+
}
17+
1418
func AddServerDecodingGzipMaxLen(ctx context.Context, maxLen int64) context.Context {
1519
return context.WithValue(ctx, reqCtxKeyGzipMaxLen, maxLen)
1620
}
1721

22+
// Used for enforcing max body content limits when dealing with chunked requests.
23+
func GetServerDecodingMaxLen(ctx context.Context) (int64, bool) {
24+
maxLength, ok := ctx.Value(reqCtxKeyMaxLen).(int64)
25+
return maxLength, ok
26+
}
27+
1828
func GetServerDecodingGzipMaxLen(ctx context.Context) (int64, bool) {
1929
gzipMaxLength, ok := ctx.Value(reqCtxKeyGzipMaxLen).(int64)
2030
return gzipMaxLength, ok

util/read_gzip_body.go

+18-7
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,24 @@ var gzipReaderPool = sync.Pool{
2727
// payload size, but not an unbounded amount of memory, as was potentially
2828
// possible before.
2929
func ReadMaybeCompressedBody(r *http.Request) ([]byte, error) {
30-
if r.ContentLength <= 0 {
31-
return []byte{}, nil
32-
}
33-
// Read content from the request body into a buffer of known size.
34-
content := bytes.NewBuffer(make([]byte, 0, r.ContentLength))
35-
if _, err := io.CopyN(content, r.Body, r.ContentLength); err != nil {
36-
return content.Bytes(), err
30+
var content *bytes.Buffer
31+
// Note(philipc): If the request body is of unknown length (such as what
32+
// happens when 'Transfer-Encoding: chunked' is set), we have to do an
33+
// incremental read of the body. In this case, we can't be too clever, we
34+
// just do the best we can with whatever is streamed over to us.
35+
// Fetch gzip payload size limit from request context.
36+
if maxLength, ok := decoding.GetServerDecodingMaxLen(r.Context()); ok {
37+
bs, err := io.ReadAll(io.LimitReader(r.Body, maxLength))
38+
if err != nil {
39+
return bs, err
40+
}
41+
content = bytes.NewBuffer(bs)
42+
} else {
43+
// Read content from the request body into a buffer of known size.
44+
content = bytes.NewBuffer(make([]byte, 0, r.ContentLength))
45+
if _, err := io.CopyN(content, r.Body, r.ContentLength); err != nil {
46+
return content.Bytes(), err
47+
}
3748
}
3849

3950
// Decompress gzip content by reading from the buffer.

0 commit comments

Comments
 (0)