Skip to content

Bound the HTTP/2 control-frame residue in duration: a connection deadline that closes with GOAWAY - #43

Merged
HackTuah merged 5 commits into
mainfrom
slice/029b-connection-deadline
Sep 17, 2026
Merged

HackTuah merged 5 commits into
mainfrom
slice/029b-connection-deadline

Conversation

@HackTuah

@HackTuah HackTuah commented Sep 17, 2026

Copy link
Copy Markdown
Member

Over HTTP/2 a stream held open by control frames alone cannot be ended from outside the adapter — the residue slice 029 recorded and 029a bounded only per-frame. This slice bounds it in duration, with a lever the package owns: the connection.

The measurement first (both numbers, unjudged). A single connection pins a body-read stream with control frames alone — a WINDOW_UPDATE, or a HEADERS without END_STREAM — one frame every 100 ms, and keeps sending for three seconds (the test HTTP/2 client 029a added under test/support sends the frames; nghttp2 will not). Today, held as long as the frames come: the stream is answered 408 at 3,341 ms (WINDOW_UPDATE) and 3,333 ms (HEADERS) for a 300 ms read deadline — unbounded in principle, since a client that never stops holds forever. Under connection_timeout: 600, the whole connection is closed with GOAWAY(NO_ERROR) at 607 and 603 ms, while the client is still sending. On a shared connection, a legitimate stream that sent a complete body is answered 200, and the held stream then gets GOAWAY(NO_ERROR, last_stream_id 3) at 602 ms.

The lever is the connection, not the stream. Bandit offers no interface to end one stream from outside, and forging its private message or killing the stream process (which leaves the client with no answer) were declined in 029a. But the connection process is a ThousandIsland.Handler GenServer, and stopping it runs the handler's own orderly termination — GOAWAY(NO_ERROR), then the socket closed, a readable close and not a reset. So connection_timeout: (a positive integer of milliseconds, default twice read_timeout) arms a watchdog per HTTP/2 body read: the read records its connection deadline in the stream's process dictionary, and at that deadline the watchdog calls GenServer.stop(handler, :shutdown) if the read is still blocked and no sibling stream on the connection is still within its own body deadline. The handler is found not by an adapter name but by the handle_shutdown/2 callback it exports — the capability the close relies on — so the lever is an OTP interface on the process, and no forgery. The GOAWAY was confirmed clean by direct probe before anything was built on it.

The cost is per connection, and it is on the row. The client's other streams still open on the connection end with the GOAWAY. A stream that already answered is unharmed; one still in flight when the close fires dies with it. So a host multiplexing streams that legitimately outlive one body read raises connection_timeout — and the watchdog already waits for the latest live deadline, so a sibling still within its own deadline is never cut off (a test pins this: a stream held past its deadline does not close the connection while a later stream is still within its own). The default is twice the read deadline — one for the body to arrive, a second before a still-blocked read is taken for a hold rather than a slow arrival (the read loop answers slow arrivals per DATA frame within read_timeout).

Red first: a pinned connection outlives the deadline on main; after the change it is closed at the deadline, and the close is a GOAWAY the client reads. Bandit does give a clean GOAWAY from outside, so there is no second finding there.

The threat-model row now states the residue as bounded in duration by the package (connection_timeout) and in count by http_2_options's max_concurrent_streams; http_2_options: [enabled: false] still removes HTTP/2 whole. The two per-stream residue tests stay — they carry a high connection_timeout so the connection bound does not close them first, and they are the offer that fails the day Bandit bounds the stream itself.

11 properties, 654 tests, 0 failures; gate thirteen pass. Five mutants over the connection deadline, zero survivors (a sixth was removed as equivalent: Bandit links a stream only to its connection, so matching any GenServer and matching the handle_shutdown/2 handler pick the same pid — no test distinguishes them).

What this does not do: a rate limiter on control frames. That is a second lever with a threshold, and a threshold is a measured number; the measurement is here, and the threshold is not proposed in code.

How to tell whether you are affected: a host without HTTP/2, or whose clients always complete or abandon a body, sees no change. A host that ran HTTP/2 and had connections pinned by control-frame floods now sees them closed with a GOAWAY at the connection deadline rather than held until the client stops.

…e adapter (029a's residue), but the connection can -- a client holding a body-read stream open with control frames alone past a connection deadline, with nothing else in flight, should have its whole connection closed with a readable GOAWAY (not a reset), measured at ~the deadline while the client keeps sending for three seconds; a legitimate stream that already answered is unharmed. The h2c test client gains stream ids on every sender and reader, request/3 to open a further stream on one connection, a persistent HPACK decode table per connection, and GOAWAY's last-stream-id; the two 029a residue tests opt out with a high connection deadline so they still record the adapter's per-stream hold -- the offer that fails the day Bandit bounds the stream: 27 tests, 3 failures

Signed-off-by: Ayla Croft <aylacroft@proton.me>
…ed in duration by a connection deadline this package owns. connection_timeout: (positive integer ms, default twice read_timeout) arms a watchdog per HTTP/2 body read: the read records its connection deadline in the stream's process dictionary and a spawned watchdog, at that deadline, closes the whole connection with GenServer.stop(handler, :shutdown) -- an OTP interface found by the handle_shutdown/2 callback the socket handler exports, not an adapter name -- which runs Bandit's orderly termination (GOAWAY NO_ERROR, then the socket), if the read is still blocked and no sibling stream on the connection is still within its own deadline (each publishes its deadline in its dictionary; the watchdog reads the connection's links). Measured: a stream pinned for 3 s under connection_timeout 600 is closed with GOAWAY at ~605 ms where unbounded it lived 3.34 s; a legitimate stream that already answered is unharmed. The two 029a residue tests opt out with a high connection_timeout and stay as the offer. The threat-model row, the moduledoc, the README and the CHANGELOG state the bound, its per-connection cost and the arithmetic; the reach census pins Process.delete/1 and the dotted list connection_timeout

Signed-off-by: Ayla Croft <aylacroft@proton.me>
… connection out from under a stream still within its own deadline; the watchdog waits for the latest live deadline) and six mutants over the connection deadline

Signed-off-by: Ayla Croft <aylacroft@proton.me>
…(a dropped clause was a compiler kill), Mcd5 arms at now via connection_timeout - connection_timeout (div/2 introduced a callee the reach census refused, proving nothing); Mcd6 removed as equivalent -- Bandit links a stream only to its connection, so matching any GenServer and matching the handle_shutdown/2 handler pick the same pid, no test distinguishes them. Five over the connection deadline, zero survivors

Signed-off-by: Ayla Croft <aylacroft@proton.me>
…are socket close, not the readable GOAWAY (a review lane's plant confirmed :normal yields {:closed, ""}); six mutants, zero survivors

Signed-off-by: Ayla Croft <aylacroft@proton.me>
@HackTuah
HackTuah merged commit 7755de8 into main Sep 17, 2026
6 checks passed
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