CVE-2026-48862
Description
Allocation of Resources Without Limits or Throttling vulnerability in elixir-mint Mint allows attacker-controlled HTTP/2 servers to exhaust memory in a Mint client via PUSH_PROMISE flooding.
In lib/mint/http2.ex, Mint.HTTP2.decode_push_promise_headers_and_add_response/5 inserts a :reserved_remote entry into conn.streams for every promised stream ID. The neighbouring Mint.HTTP2.assert_valid_promised_stream_id/2 only verifies that the promised ID is even and not already present; client_settings.max_concurrent_streams is not consulted at promise time. The concurrency cap is only checked when the response HEADERS for the promised stream arrive, so a server that emits PUSH_PROMISE frames and withholds the matching HEADERS never trips that check.
HTTP/2 server push is accepted by default (client_settings.enable_push defaults to true). A single long-lived HTTP/2 connection to a hostile server lets that server pin one conn.streams entry per PUSH_PROMISE frame it sends, with no upper bound, until the client process runs out of memory.
This issue affects mint: from 0.2.0 before 1.9.0.
Affected products
1- Range: >=0.2.0 <1.9.0
Patches
170b97b6a5209Merge commit from fork
2 files changed · +96 −34
lib/mint/http2.ex+43 −12 modified@@ -224,6 +224,7 @@ defmodule Mint.HTTP2 do streams: %{}, open_client_stream_count: 0, open_server_stream_count: 0, + reserved_server_stream_count: 0, ref_to_stream_id: %{}, # Settings that the server communicates to the client. @@ -1994,6 +1995,7 @@ defmodule Mint.HTTP2 do true -> conn = update_in(conn.open_server_stream_count, &(&1 + 1)) + conn = update_in(conn.reserved_server_stream_count, &(&1 - 1)) conn = put_in(conn.streams[stream.id].state, :half_closed_local) {conn, new_responses} end @@ -2262,21 +2264,45 @@ defmodule Mint.HTTP2 do stream, promised_stream_id ) do + # The header block fragment must always be decoded to keep the HPACK decode + # table in sync with the server, even when we end up refusing the stream. {conn, headers} = decode_hbf(conn, hbf) - promised_stream = %{ - id: promised_stream_id, - ref: make_ref(), - state: :reserved_remote, - send_window_size: conn.server_settings.initial_window_size, - receive_window_size: conn.client_settings.initial_window_size, - receive_window_remaining: conn.client_settings.initial_window_size, - received_first_headers?: false - } + # A reserved stream stays in `conn.streams` until its response HEADERS + # arrive, so promised streams have to be counted against the concurrency + # limit at promise time. Otherwise a server can pin an unbounded number of + # reserved streams by sending PUSH_PROMISE frames and never following up + # with the HEADERS that would open them. + server_stream_count = conn.open_server_stream_count + conn.reserved_server_stream_count - conn = put_in(conn.streams[promised_stream.id], promised_stream) - new_response = {:push_promise, stream.ref, promised_stream.ref, headers} - {conn, [new_response | responses]} + if server_stream_count >= conn.client_settings.max_concurrent_streams do + conn = refuse_promised_stream(conn, promised_stream_id) + {conn, responses} + else + promised_stream = %{ + id: promised_stream_id, + ref: make_ref(), + state: :reserved_remote, + send_window_size: conn.server_settings.initial_window_size, + receive_window_size: conn.client_settings.initial_window_size, + receive_window_remaining: conn.client_settings.initial_window_size, + received_first_headers?: false + } + + conn = put_in(conn.streams[promised_stream.id], promised_stream) + conn = update_in(conn.reserved_server_stream_count, &(&1 + 1)) + new_response = {:push_promise, stream.ref, promised_stream.ref, headers} + {conn, [new_response | responses]} + end + end + + defp refuse_promised_stream(conn, promised_stream_id) do + if open?(conn) do + rst_stream_frame = rst_stream(stream_id: promised_stream_id, error_code: :refused_stream) + send!(conn, Frame.encode(rst_stream_frame)) + else + conn + end end defp assert_valid_promised_stream_id(conn, promised_stream_id) do @@ -2509,6 +2535,11 @@ defmodule Mint.HTTP2 do stream_open? and Integer.is_even(stream.id) -> update_in(conn.open_server_stream_count, &(&1 - 1)) + # Stream reserved by the server through a PUSH_PROMISE, but whose + # response HEADERS never arrived to open it. + stream.state == :reserved_remote -> + update_in(conn.reserved_server_stream_count, &(&1 - 1)) + true -> conn end
test/mint/http2/conn_test.exs+53 −22 modified@@ -1631,7 +1631,7 @@ defmodule Mint.HTTP2Test do end @tag connect_options: [client_settings: [max_concurrent_streams: 1]] - test "if the server reaches the max number of client streams, the client sends an error", + test "promised streams beyond max_concurrent_streams are refused at promise time", %{conn: conn} do {conn, ref} = open_request(conn) @@ -1661,37 +1661,68 @@ defmodule Mint.HTTP2Test do ) ]) + # Only the first promise is accepted: it fills the single available slot. + # The second is refused at decode time, before it can be inserted into the + # streams map, so it never surfaces as a :push_promise response. assert [ - {:push_promise, ^ref, promised_ref1, _}, - {:push_promise, ^ref, _promised_ref2, _}, + {:push_promise, ^ref, _promised_ref1, _}, {:status, ^ref, 200}, {:headers, ^ref, []}, {:done, ^ref} ] = responses - # Here we send headers for the two promised streams. Note that neither of the - # header frames have the END_STREAM flag set otherwise we close the streams and - # they don't count towards the open stream count. - assert {:ok, %HTTP2{} = conn, responses} = - stream_frames(conn, [ - headers( - stream_id: 4, - hbf: normal_headers_hbf, - flags: set_flags(:headers, [:end_headers]) - ), - headers( - stream_id: 6, - hbf: normal_headers_hbf, - flags: set_flags(:headers, [:end_headers]) - ) - ]) - - assert [{:status, ^promised_ref1, 200}, {:headers, ^promised_ref1, []}] = responses - assert_recv_frames [ rst_stream(stream_id: 6, error_code: :refused_stream) ] + refute Map.has_key?(conn.streams, 6) + assert HTTP2.open?(conn) + end + + @tag connect_options: [client_settings: [max_concurrent_streams: 5]] + test "a flood of PUSH_PROMISE frames cannot grow the streams map past max_concurrent_streams", + %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + promised_headers_hbf = server_encode_headers([{":method", "GET"}]) + + # The server promises many more streams than the client's limit but never + # follows up with the response HEADERS for any of them. Each promise must + # still be HPACK-decoded to keep the decode table in sync, but only the + # first five may be retained as reserved streams. + promised_ids = Enum.map(1..100, &(&1 * 2 + 2)) + + promise_frames = + Enum.map(promised_ids, fn promised_stream_id -> + push_promise( + stream_id: stream_id, + hbf: promised_headers_hbf, + promised_stream_id: promised_stream_id, + flags: set_flags(:push_promise, [:end_headers]) + ) + end) + + assert {:ok, %HTTP2{} = conn, responses} = stream_frames(conn, promise_frames) + + # Only five promises surface as responses; the rest are refused. + assert length(responses) == 5 + + reserved_ids = + for {id, %{state: :reserved_remote}} <- conn.streams, do: id + + assert length(reserved_ids) == 5 + assert reserved_ids == Enum.take(promised_ids, 5) + + # Every refused promise gets a RST_STREAM with REFUSED_STREAM. + refused_ids = Enum.drop(promised_ids, 5) + rst_frames = recv_next_frames(length(refused_ids)) + + for {frame, expected_id} <- Enum.zip(rst_frames, refused_ids) do + assert rst_stream(stream_id: ^expected_id, error_code: :refused_stream) = frame + end + assert HTTP2.open?(conn) end end
Vulnerability mechanics
Root cause
"The HTTP/2 client does not enforce the maximum concurrent streams limit when processing PUSH_PROMISE frames."
Attack vector
An attacker controls an HTTP/2 server and establishes a long-lived connection with a Mint client. The attacker then sends a flood of PUSH_PROMISE frames without sending the corresponding HEADERS for the promised streams. This causes the client to allocate an unbounded number of stream entries in its internal state, eventually leading to memory exhaustion [ref_id=2].
Affected code
The vulnerability resides in `lib/mint/http2.ex`, specifically within the `decode_push_promise_headers_and_add_response/5` function. This function handles inbound PUSH_PROMISE frames and adds entries to the `conn.streams` map without validating against the client's maximum concurrent streams setting.
What the fix does
The patch modifies `Mint.HTTP2.decode_push_promise_headers_and_add_response/5` to check the `max_concurrent_streams` setting before accepting a PUSH_PROMISE frame [patch_id=4518413]. If the limit is reached, the promised stream is refused with a RST_STREAM error, preventing the `conn.streams` map from growing indefinitely. The count of reserved streams is now correctly managed against the concurrency limit [ref_id=1].
Preconditions
- configHTTP/2 server push must be enabled on the client, which is the default behavior [ref_id=2].
- networkThe Mint client must establish an HTTP/2 connection with a server that can be controlled by the attacker.
Reproduction
Stand up a raw TCP HTTP/2 server that completes the handshake and ACKs the client's SETTINGS. Wait for the client's request HEADERS and capture its odd stream ID. Send a flood of PUSH_PROMISE frames (flags = END_HEADERS) associated with the captured stream, each promising a fresh even stream ID and carrying a minimal HPACK-encoded header block. Never send the matching response HEADERS for any of the promised IDs. The client's conn.streams map grows by one entry per PUSH_PROMISE frame (~148 bytes/entry); memory grows linearly and the BEAM process eventually crashes with OOM [ref_id=2].
Generated on Jun 2, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.