fix: close connections with unread chunked bodies

A streamed chunked request can leave bytes queued after the handler returns.
Reusing that connection lets fasthttp parse those bytes as the next request,
allowing a shared upstream proxy connection to mix requests across tenants.
Mark chunked requests Connection: close when the middleware cannot safely
drain them, preventing leftover bytes from crossing the request boundary.

Draining is intentionally skipped because fasthttp's request stream reads past
the terminating chunk when probing for EOF and can block waiting for another
chunk header. The connection-reuse sacrifice is therefore required to avoid both
request desynchronization and delaying the response. Content-Length bodies
retain the existing bounded drain behavior.
This commit is contained in:
Ben McClelland
2026-09-08 15:36:01 -07:00
parent 785c80110e
commit c36696620a
2 changed files with 41 additions and 4 deletions
+5 -4
View File
@@ -81,11 +81,12 @@ func drainRequestBody(ctx fiber.Ctx) {
// body, but not for a chunked one: past the terminating chunk it goes back
// to the socket for another chunk header that will never come. Reading a
// chunked body the handler already finished would block until the deadline
// and hold the response back with it, so only Content-Length framing is
// drained. Nothing is lost for the aws-chunked uploads this exists for --
// STREAMING-* payloads carry a Content-Length.
// and hold the response back with it, so do not drain chunked framing. The
// stream may still have unread bytes, though, so the connection must not be
// reused for another request.
cLength := ctx.Request().Header.ContentLength()
if cLength <= 0 {
if cLength < 0 {
ctx.Response().Header.SetConnectionClose()
return
}
@@ -206,6 +206,42 @@ func TestDrainRequestBody_doesNotStallAChunkedBodyTheHandlerFinished(t *testing.
}
}
func TestDrainRequestBody_closesConnectionForUnreadChunkedBody(t *testing.T) {
addr := startEarlyResponder(t, false)
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
t.Fatalf("set deadline: %v", err)
}
// Leave one decoded byte unread after the handler's initial read. The
// middleware cannot safely probe for EOF on a chunked request.
chunk := bytes.Repeat([]byte("a"), handlerReadBytes+1)
body := fmt.Appendf(nil, "PUT /object HTTP/1.1\r\nHost: %s\r\nTransfer-Encoding: chunked\r\n\r\n%x\r\n", addr, len(chunk))
body = append(body, chunk...)
body = append(body, []byte("\r\n0\r\n\r\n")...)
if _, err := conn.Write(body); err != nil {
t.Fatalf("write request: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read response: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected status %v, got %v", http.StatusBadRequest, resp.StatusCode)
}
if !resp.Close {
t.Fatal("expected 'Connection: close' for an unread chunked body")
}
}
// The same, for a Content-Length body: fasthttp reports EOF idempotently there,
// so it is drained, but a handler that already finished it must not be delayed.
func TestDrainRequestBody_doesNotStallABodyTheHandlerFinished(t *testing.T) {