Bound the HTTP/2 control-frame residue in duration: a connection deadline that closes with GOAWAY - #43
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/supportsends the frames; nghttp2 will not). Today, held as long as the frames come: the stream is answered408at 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. Underconnection_timeout: 600, the whole connection is closed withGOAWAY(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 answered200, and the held stream then getsGOAWAY(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.HandlerGenServer, and stopping it runs the handler's own orderly termination —GOAWAY(NO_ERROR), then the socket closed, a readable close and not a reset. Soconnection_timeout:(a positive integer of milliseconds, default twiceread_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 callsGenServer.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 thehandle_shutdown/2callback 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 withinread_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 byhttp_2_options'smax_concurrent_streams;http_2_options: [enabled: false]still removes HTTP/2 whole. The two per-stream residue tests stay — they carry a highconnection_timeoutso 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 thirteenpass. 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 thehandle_shutdown/2handler 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
GOAWAYat the connection deadline rather than held until the client stops.