feat: support bounded chunked request bodies
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
//
|
||||
// It deliberately keeps the public handler boundary as net/http so existing
|
||||
// GoOmni business code remains unchanged while connection management and
|
||||
// protocol I/O move out of net/http.Server. The parser currently supports
|
||||
// Content-Length request bodies and rejects chunked requests.
|
||||
// protocol I/O move out of net/http.Server. The parser supports Content-Length
|
||||
// and chunked request bodies with configurable size limits.
|
||||
package native
|
||||
|
||||
import (
|
||||
@@ -493,14 +493,23 @@ func readRequest(reader *bufio.Reader, conn net.Conn, maxHeaderBytes int, maxBod
|
||||
return nil, true, &protocolError{status: http.StatusExpectationFailed, message: "unsupported expectation"}
|
||||
}
|
||||
}
|
||||
chunked := false
|
||||
for _, value := range header.Values("Transfer-Encoding") {
|
||||
for _, coding := range strings.Split(value, ",") {
|
||||
coding = strings.TrimSpace(coding)
|
||||
if coding != "" && !strings.EqualFold(coding, "identity") {
|
||||
return nil, true, &protocolError{status: http.StatusNotImplemented, message: "transfer encoding is not supported"}
|
||||
if coding == "" || strings.EqualFold(coding, "identity") {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(coding, "chunked") && !chunked {
|
||||
chunked = true
|
||||
continue
|
||||
}
|
||||
return nil, true, &protocolError{status: http.StatusNotImplemented, message: "transfer encoding is not supported"}
|
||||
}
|
||||
}
|
||||
if chunked && len(header.Values("Content-Length")) > 0 {
|
||||
return nil, true, &protocolError{status: http.StatusBadRequest, message: "content length with chunked encoding"}
|
||||
}
|
||||
contentLength := int64(0)
|
||||
contentLengthSeen := false
|
||||
for _, value := range header.Values("Content-Length") {
|
||||
@@ -514,17 +523,31 @@ func readRequest(reader *bufio.Reader, conn net.Conn, maxHeaderBytes int, maxBod
|
||||
contentLengthSeen = true
|
||||
}
|
||||
}
|
||||
if contentLength > maxBodyBytes {
|
||||
if !chunked && contentLength > maxBodyBytes {
|
||||
return nil, true, &protocolError{status: http.StatusRequestEntityTooLarge, message: "request body too large"}
|
||||
}
|
||||
body := make([]byte, contentLength)
|
||||
if contentLength > 0 {
|
||||
if setBodyDeadline != nil {
|
||||
setBodyDeadline()
|
||||
}
|
||||
if _, err := io.ReadFull(reader, body); err != nil {
|
||||
var body []byte
|
||||
var trailer http.Header
|
||||
if chunked {
|
||||
body, trailer, err = readChunkedBody(reader, maxBodyBytes, setBodyDeadline, readLine)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
// net/http uses -1 to indicate that the decoded length was not known
|
||||
// from the request headers. The body is already buffered for the
|
||||
// handler, but retaining this value keeps the request contract
|
||||
// compatible with the standard server.
|
||||
contentLength = -1
|
||||
} else {
|
||||
body = make([]byte, contentLength)
|
||||
if contentLength > 0 {
|
||||
if setBodyDeadline != nil {
|
||||
setBodyDeadline()
|
||||
}
|
||||
if _, err := io.ReadFull(reader, body); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
}
|
||||
}
|
||||
request := &http.Request{
|
||||
Method: parts[0],
|
||||
@@ -538,6 +561,10 @@ func readRequest(reader *bufio.Reader, conn net.Conn, maxHeaderBytes int, maxBod
|
||||
Host: header.Get("Host"),
|
||||
RequestURI: target,
|
||||
RemoteAddr: conn.RemoteAddr().String(),
|
||||
Trailer: trailer,
|
||||
}
|
||||
if chunked {
|
||||
request.TransferEncoding = []string{"chunked"}
|
||||
}
|
||||
closeAfter := headerHasToken(header.Values("Connection"), "close") ||
|
||||
(protoMajor == 1 && protoMinor == 0 && !headerHasToken(header.Values("Connection"), "keep-alive"))
|
||||
@@ -545,6 +572,90 @@ func readRequest(reader *bufio.Reader, conn net.Conn, maxHeaderBytes int, maxBod
|
||||
return request, closeAfter, nil
|
||||
}
|
||||
|
||||
func readChunkedBody(reader *bufio.Reader, maxBodyBytes int64, setBodyDeadline func(), readLine func() (string, error)) ([]byte, http.Header, error) {
|
||||
var body bytes.Buffer
|
||||
initialCapacity := int64(64 << 10)
|
||||
if maxBodyBytes < initialCapacity {
|
||||
initialCapacity = maxBodyBytes
|
||||
}
|
||||
if initialCapacity > 0 && initialCapacity <= int64(int(^uint(0)>>1)) {
|
||||
body.Grow(int(initialCapacity))
|
||||
}
|
||||
|
||||
for {
|
||||
line, err := readLine()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
chunkSizeText := line
|
||||
if extension := strings.IndexByte(chunkSizeText, ';'); extension >= 0 {
|
||||
chunkSizeText = chunkSizeText[:extension]
|
||||
}
|
||||
chunkSizeText = strings.TrimSpace(chunkSizeText)
|
||||
if chunkSizeText == "" || !validHexNumber(chunkSizeText) {
|
||||
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "invalid chunk size"}
|
||||
}
|
||||
chunkSize, parseErr := strconv.ParseUint(chunkSizeText, 16, 63)
|
||||
if parseErr != nil {
|
||||
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "invalid chunk size"}
|
||||
}
|
||||
if chunkSize == 0 {
|
||||
trailer := make(http.Header)
|
||||
for {
|
||||
line, err = readLine()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if line == "" {
|
||||
return body.Bytes(), trailer, nil
|
||||
}
|
||||
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
|
||||
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "folded trailers are not supported"}
|
||||
}
|
||||
index := strings.IndexByte(line, ':')
|
||||
if index <= 0 {
|
||||
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "malformed trailer"}
|
||||
}
|
||||
name := strings.TrimSpace(line[:index])
|
||||
value := strings.TrimSpace(line[index+1:])
|
||||
if !validToken(name) || !validHeaderValue(value) {
|
||||
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "malformed trailer"}
|
||||
}
|
||||
trailer.Add(name, value)
|
||||
}
|
||||
}
|
||||
if int64(body.Len()) > maxBodyBytes-int64(chunkSize) {
|
||||
return nil, nil, &protocolError{status: http.StatusRequestEntityTooLarge, message: "request body too large"}
|
||||
}
|
||||
if setBodyDeadline != nil {
|
||||
setBodyDeadline()
|
||||
}
|
||||
if _, err := io.CopyN(&body, reader, int64(chunkSize)); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if setBodyDeadline != nil {
|
||||
setBodyDeadline()
|
||||
}
|
||||
var suffix [2]byte
|
||||
if _, err := io.ReadFull(reader, suffix[:]); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if suffix[0] != '\r' || suffix[1] != '\n' {
|
||||
return nil, nil, &protocolError{status: http.StatusBadRequest, message: "malformed chunk terminator"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validHexNumber(value string) bool {
|
||||
for index := 0; index < len(value); index++ {
|
||||
char := value[index]
|
||||
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseProtocol(value string) (int, int, bool) {
|
||||
if value != "HTTP/1.1" && value != "HTTP/1.0" {
|
||||
return 0, 0, false
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestServerKeepAliveAndApplicationBridge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRejectsUnsupportedBodyAndLimits(t *testing.T) {
|
||||
func TestServerRejectsMalformedBodyAndLimits(t *testing.T) {
|
||||
application := omni.NewWithRouter(netrouter.New())
|
||||
application.POST("/body", func(ctx *omni.Context) error { return ctx.OK("ok") })
|
||||
|
||||
@@ -67,28 +67,85 @@ func TestServerRejectsUnsupportedBodyAndLimits(t *testing.T) {
|
||||
go func() { done <- server.Serve(context.Background(), listener) }()
|
||||
defer stopServer(t, server, listener, done)
|
||||
|
||||
for _, requestText := range []string{
|
||||
"POST /body HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ntest\r\n0\r\n\r\n",
|
||||
"POST /body HTTP/1.1\r\nHost: test\r\nContent-Length: 5\r\n\r\ntest!",
|
||||
} {
|
||||
tests := []struct {
|
||||
request string
|
||||
status int
|
||||
}{
|
||||
{
|
||||
request: "POST /body HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\n\r\nZ\r\nbad\r\n0\r\n\r\n",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
request: "POST /body HTTP/1.1\r\nHost: test\r\nContent-Length: 5\r\n\r\ntest!",
|
||||
status: http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
conn, err := net.Dial("tcp", listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(conn, requestText)
|
||||
_, _ = io.WriteString(conn, test.request)
|
||||
response, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: http.MethodPost})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusNotImplemented && response.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("unexpected protocol status %d", response.StatusCode)
|
||||
if response.StatusCode != test.status {
|
||||
t.Fatalf("expected status %d, got %d", test.status, response.StatusCode)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerChunkedBodyAndTrailers(t *testing.T) {
|
||||
application := omni.NewWithRouter(netrouter.New())
|
||||
application.POST("/body", func(ctx *omni.Context) error {
|
||||
body, err := io.ReadAll(ctx.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.OK(map[string]string{
|
||||
"body": string(body),
|
||||
"trailer": ctx.Request().Trailer.Get("X-Trace"),
|
||||
"transfer": strings.Join(ctx.Request().TransferEncoding, ","),
|
||||
})
|
||||
})
|
||||
server, listener, done := startServer(t, application)
|
||||
defer stopServer(t, server, listener, done)
|
||||
|
||||
request := "POST /body HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" +
|
||||
"4;source=wire\r\nWiki\r\n5\r\npedia\r\n0\r\nX-Trace: yes\r\n\r\n"
|
||||
response := rawResponseMethod(t, listener.Addr().String(), http.MethodPost, request)
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil || response.StatusCode != http.StatusOK || !strings.Contains(string(body), `"body":"Wikipedia"`) || !strings.Contains(string(body), `"trailer":"yes"`) || !strings.Contains(string(body), `"transfer":"chunked"`) {
|
||||
t.Fatalf("unexpected chunked response: status=%d body=%s error=%v", response.StatusCode, body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerChunkedBodyLimit(t *testing.T) {
|
||||
application := omni.NewWithRouter(netrouter.New())
|
||||
application.POST("/body", func(ctx *omni.Context) error { return ctx.OK("unexpected") })
|
||||
server := native.New(application)
|
||||
server.MaxBodyBytes = 4
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- server.Serve(context.Background(), listener) }()
|
||||
defer stopServer(t, server, listener, done)
|
||||
|
||||
request := "POST /body HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n"
|
||||
response := rawResponseMethod(t, listener.Addr().String(), http.MethodPost, request)
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("expected chunked body limit status 413, got %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSlowBodyTimesOutBeforeHandler(t *testing.T) {
|
||||
var handlerCalls atomic.Int32
|
||||
application := omni.NewWithRouter(netrouter.New())
|
||||
@@ -210,9 +267,9 @@ func TestServerProtocolBoundaries(t *testing.T) {
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "chunked",
|
||||
request: "POST /health HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\n\r\n",
|
||||
status: http.StatusNotImplemented,
|
||||
name: "malformed chunked",
|
||||
request: "POST /health HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\n\r\nZ\r\n\r\n",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "expectation",
|
||||
|
||||
Reference in New Issue
Block a user