Skip to content

Reject transfer-encoding in HTTP/1.0 messages and non-chunked request bodies. - #66

Open
samuel-williams-shopify wants to merge 6 commits into
mainfrom
strict-transfer-encoding-framing
Open

samuel-williams-shopify wants to merge 6 commits into
mainfrom
strict-transfer-encoding-framing

Conversation

@samuel-williams-shopify

@samuel-williams-shopify samuel-williams-shopify commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Connection#read_body now rejects message framings that RFC 9112 requires recipients to treat as faulty: any HTTP/1.0 message carrying transfer-encoding, any request whose transfer coding is anything other than exactly chunked, and any message applying chunked more than once. All raise BadRequest and mark the connection non-persistent so it is not reused. Request and status line parsing now only accepts HTTP/1.x versions, and the upgrade header is ignored in HTTP/1.0 requests, closing the paths by which the framing checks could be sidestepped.

Problem

HTTP/1.1 message framing was more lenient than the specification allows, which matters when protocol-http1 sits behind a stricter front end (proxy or load balancer) that frames the same bytes differently — the classic building block for request smuggling.

  1. An HTTP/1.0 request or response with Transfer-Encoding: chunked was accepted and fully decoded (including trailers). RFC 9112 §6.1: a server or client receiving an HTTP/1.0 message containing Transfer-Encoding MUST treat the message as if the framing is faulty, even if Content-Length is present, and close the connection.
  2. A request with Transfer-Encoding where chunked is not the final coding (e.g. gzip or chunked, gzip) was read as a close-delimited Body::Remainder. RFC 9112 §6.3: the server MUST respond 400 and close the connection, because the body length cannot be determined reliably. Close-delimited reading is only correct for responses.
  3. A request with Transfer-Encoding: gzip, chunked was accepted as Body::Chunked, and since read_body consumes the transfer-encoding header, the application received a still-gzip-coded body with no indication of the coding. RFC 9112 §6.1: a server SHOULD reject a transfer coding it does not understand.
  4. Transfer-Encoding: chunked, chunked (or two separate Transfer-Encoding: chunked headers, which the header policy merges) was accepted on both requests and responses. RFC 9112 §7.1: chunked MUST NOT be applied more than once.
  5. REQUEST_LINE matched HTTP\/\d.\d with an unescaped . and any major version, so HTTP/0.9, HTTP/2.0 and HTTP/1x1 were parsed and treated as HTTP/1.1 — sidestepping the literal HTTP/1.0 check above. The client did not validate the status line version at all, so http/1.1 200 OK was accepted despite HTTP-version being case-sensitive (RFC 9112 §2.3).
  6. Upgrade on an HTTP/1.0 request switched to close-delimited reading, bypassing the transfer-encoding check. RFC 9110 §7.8: a server MUST ignore Upgrade in an HTTP/1.0 request.

Solution

  • read_request and read_response now pass the parsed version through read_request_body / read_response_body to read_body. The new parameter is a trailing optional positional (version = nil) so existing callers (including async-http, which only uses read_request / read_response) are unaffected; when the version is unknown the HTTP/1.0 check is skipped but all other framing checks still apply.
  • In read_body, when transfer-encoding is present:
    • If version == HTTP10, raise BadRequest and set @persistent = false.
    • The existing transfer-encoding + content-length rejection now also sets @persistent = false, since the RFC requires closing the connection in that case too.
    • If chunked appears more than once, raise BadRequest and set @persistent = false (requests and responses).
    • Responses (remainder == true): chunked last → Body::Chunked (e.g. gzip, chunked still decodes the chunked layer, leaving the remaining coding to the application, as before); otherwise read until close as before.
    • Requests: only exactly [chunked] is accepted; anything else raises BadRequest and sets @persistent = false.
  • REQUEST_LINE now uses HTTP\/1\.\d; read_request_line raises InvalidRequest for anything else. read_response_line validates the version against the same pattern and raises BadRequest. Unknown minor versions such as HTTP/1.2 remain accepted and are treated as HTTP/1.1 (RFC 9110 §2.5).
  • read_request_body only takes the upgrade path when version != HTTP10, so HTTP/1.0 requests fall through to read_body. The existing upgrade test, which happened to use HTTP/1.0, now uses HTTP/1.1.
  • read_request, read_response, read_request_body and read_response_body now document the BadRequest they propagate. The read_body documentation records the client-side trade-off: browsers ignore transfer-encoding on HTTP/1.0 responses and read to close; if interoperability ever requires relaxing this, the correct relaxation is close-delimited reading, never chunked decoding.

In async-http, Protocol::HTTP1::Server#next_request already rescues Protocol::HTTP::BadRequest, writes a 400 Bad Request with connection: close, and re-raises so the connection is torn down — verified end-to-end against a local Async::HTTP::Server for the rejection cases. On the client side, read_response raises BadRequest (consistent with the existing handling of transfer-encoding + content-length in responses).

Tests

Added to test/protocol/http1/connection.rb and test/protocol/http1/upgrade.rb:

  • HTTP/1.0 request with transfer-encoding: chunked (with and without content-length, both with Connection: keep-alive so the persistence assertion is meaningful) raises BadRequest; persistent is false.
  • HTTP/1.1 request with transfer-encoding: gzip, chunked, gzip, gzip, chunked, chunked, chunked, or two transfer-encoding: chunked headers raises BadRequest; persistent is false.
  • HTTP/1.1 request with both transfer-encoding and content-length raises BadRequest; persistent is false.
  • HTTP/1.1 chunked request (including trailers) still decodes correctly and remains persistent.
  • HTTP/1.1 chunked response still decodes; gzip, chunked response still decodes as Body::Chunked; transfer-encoding: gzip response is still read close-delimited as Body::Remainder; chunked, chunked and duplicate chunked headers raise BadRequest.
  • HTTP/1.0 response with transfer-encoding: chunked raises BadRequest on the client; persistent is false.
  • Request lines with HTTP/0.9, HTTP/2.0, HTTP/1x1, http/1.1 raise InvalidRequest; HTTP/1.2 is accepted. Status lines with the same invalid versions raise BadRequest; HTTP/1.2 is accepted.
  • HTTP/1.0 request with Upgrade is read with a nil body rather than Body::Remainder, and Upgrade + transfer-encoding: chunked on HTTP/1.0 is still rejected.
  • read_body(headers, false) called directly without a version (the original signature) still accepts chunked and rejects gzip for requests, and still reads gzip responses until close.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant