Skip to content

Add a request argument to write_body so HTTP/1.0 request bodies of unknown length raise instead of being close-delimited. - #65

Open
samuel-williams-shopify wants to merge 2 commits into
mainfrom
http10-request-body-length
Open

samuel-williams-shopify wants to merge 2 commits into
mainfrom
http10-request-body-length

Conversation

@samuel-williams-shopify

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

Copy link
Copy Markdown
Contributor

Protocol::HTTP1::Connection#write_body takes a trailing request argument; when true, an HTTP/1.0 body of unknown length raises ProtocolError instead of being written close-delimited. HTTP/1.0 responses continue to be delimited by closing the connection, as before.

Problem

A request body cannot be close-delimited. Servers (RFC 1945 §7.2.2, RFC 9112 §6.3; verified against Go net/http and protocol-http1's own server) read zero body bytes for an HTTP/1.0 request without content-length or transfer-encoding, and either discard the remaining bytes or, if the connection is kept alive, parse them as the next request.

write_body currently has one fall-through for an HTTP/1.0 body that is non-empty with no length: write_body_and_close. That framing is only meaningful for a response. When a client calls it for a request (as async-http's HTTP/1 client does for any unknown-length body, since HTTP/1.0 has no chunked encoding), the body bytes are written where the server expects the next request.

Verification

Against a Go net/http server: POST /b HTTP/1.0 with Connection: keep-alive and a body of GET /smuggled HTTP/1.1\r\nHost: localhost\r\n\r\n, followed by a half-close. Go served /b with an empty body and then served /smuggled. Without keep-alive, the body bytes were discarded. The same happens with protocol-http1's own server: the new test shows the GET /other bytes would otherwise reach the wire, and the companion async-http test shows the application being invoked for both / and /smuggled on main.

Solution

write_body(version, body, head = false, trailer = nil, request = false). The new argument mirrors head: the caller already knows whether it is writing a request or a response, so the framing decision is made from that rather than from connection state. In the HTTP/1.0 unknown-length branch:

  • request true: the body is closed with a ProtocolError (Cannot write #{version} request body of unknown length!) which is then raised, before any body bytes are written. HTTP/1.0 clients must buffer such bodies to obtain a length.
  • otherwise: unchanged, @persistent = false and write_body_and_close.

After the raise, the connection is still :open: the request head has been written without the terminating blank line. The @raises documentation states that the caller must either write a body with a known length or close the connection, and that calling read_response without doing so will deadlock. The test pins this by asserting the client is :open? before closing it, and that only the head reached the server.

The Traces::Provider wrapper for write_body accepts and records the new argument. The parameter is last with a default, so existing positional callers are unaffected.

Downstream

socketry/async-http#260 passes true from Protocol::HTTP1::Client and bumps its protocol-http1 constraint to ~> 0.42, so it must not merge before this is released. Because that client writes the body in a child task which closes the connection on failure, the error currently surfaces to users as Cannot read response in state: closed! rather than this message; surfacing the original error is a separate follow-up there.

Tests

In test/protocol/http1/connection.rb:

  • Under with "#write_body": "can write closed body" (response, default) still dispatches to write_body_and_close; "refuses to write closed body for a request" asserts that request = true raises, closes the body, leaves the connection open and writes nothing; "can write chunked body for a request" asserts HTTP/1.1 with request = true still uses chunked encoding.
  • Under with "HTTP/1.0 request body", driven through a real client/server pair with request = true: a known-length body is read back as a fixed body with the connection persistent; an empty unknown-length body still produces content-length: 0; an unknown-length body raises, closes the body, leaves the client :open?, and the server's stream contains exactly the request head and none of the GET /other bytes.
  • The existing "transitions to the closed state when using connection: close response body" continues to cover the HTTP/1.0 close-delimited response path end to end.

@samuel-williams-shopify samuel-williams-shopify changed the title Refuse to write HTTP/1.0 request bodies of unknown length. Add a request argument to write_body so HTTP/1.0 request bodies of unknown length raise instead of being close-delimited. Nov 5, 2026

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