CVE-2026-49754
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 (HTTP/2 CONTINUATION flood).
When Mint's HTTP/2 receive path observes a HEADERS frame without the END_HEADERS flag, the unparsed header-block fragment is parked in conn.headers_being_processed, and every subsequent CONTINUATION frame on that stream is appended to the accumulator. Nothing in the receive path caps the accumulator: there is no per-stream size limit, no CONTINUATION frame-count limit, and max_header_list_size is only enforced on outgoing requests, never on inbound header blocks (its default is :infinity).
A malicious or compromised HTTP/2 server can stream an endless sequence of CONTINUATION frames (each up to the peer-advertised SETTINGS_MAX_FRAME_SIZE) and drive the client's iolist to arbitrary size, causing memory exhaustion and BEAM process death. A single connection to an attacker-controlled HTTP/2 endpoint is sufficient.
This issue affects mint: from 0.1.0 before 1.9.0.
Affected products
1- Range: from 0.1.0 before 1.9.0
Patches
1b662d127d302Merge commit from fork
2 files changed · +123 −8
lib/mint/http2.ex+56 −8 modified@@ -156,6 +156,12 @@ defmodule Mint.HTTP2 do @default_max_frame_size 16_384 @valid_max_frame_size_range @default_max_frame_size..16_777_215 + # Default cap on the size of an inbound header block. Advertised to the + # server as SETTINGS_MAX_HEADER_LIST_SIZE and enforced while accumulating + # HEADERS/CONTINUATION fragments, so that a server cannot exhaust client + # memory by streaming an unbounded chain of CONTINUATION frames. + @default_max_header_list_size 256 * 1024 + @valid_client_settings [ :max_concurrent_streams, :initial_window_size, @@ -235,7 +241,7 @@ defmodule Mint.HTTP2 do client_settings: %{ max_concurrent_streams: 100, initial_window_size: @default_stream_window_size, - max_header_list_size: :infinity, + max_header_list_size: @default_max_header_list_size, max_frame_size: @default_max_frame_size, enable_push: true }, @@ -297,7 +303,12 @@ defmodule Mint.HTTP2 do * `:max_frame_size` - corresponds to `SETTINGS_MAX_FRAME_SIZE`. Tells what is the maximum size of an HTTP/2 frame for the peer that sends this setting. - * `:max_header_list_size` - corresponds to `SETTINGS_MAX_HEADER_LIST_SIZE`. + * `:max_header_list_size` - corresponds to `SETTINGS_MAX_HEADER_LIST_SIZE`. For the + client, this also bounds the size of an inbound header block (a HEADERS frame plus + its trailing CONTINUATION frames): the connection is closed with a connection error + if a server streams a header block larger than this value, which prevents a server + from exhausting client memory with an unbounded chain of CONTINUATION frames. + Defaults to `256 KB` for the client. * `:enable_connect_protocol` - corresponds to `SETTINGS_ENABLE_CONNECT_PROTOCOL`. Sets whether the client may invoke the extended connect protocol which is used to @@ -1140,7 +1151,9 @@ defmodule Mint.HTTP2 do client_settings_params = Keyword.get(opts, :client_settings, []) client_settings_params = - Keyword.put_new(client_settings_params, :initial_window_size, @default_stream_window_size) + client_settings_params + |> Keyword.put_new(:initial_window_size, @default_stream_window_size) + |> Keyword.put_new(:max_header_list_size, @default_max_header_list_size) validate_client_settings!(client_settings_params) # If the port is the default for the scheme, don't add it to the :authority pseudo-header @@ -1728,7 +1741,7 @@ defmodule Mint.HTTP2 do {nil, _frame} -> :ok - {{stream_id, _, _}, continuation(stream_id: stream_id)} -> + {{stream_id, _, _, _}, continuation(stream_id: stream_id)} -> :ok _other -> @@ -1906,7 +1919,7 @@ defmodule Mint.HTTP2 do decode_hbf_and_add_responses(conn, responses, hbf, stream, end_stream?) else callback = &decode_hbf_and_add_responses(&1, &2, &3, &4, end_stream?) - conn = put_in(conn.headers_being_processed, {stream_id, hbf, callback}) + conn = start_headers_being_processed(conn, stream_id, hbf, callback) {conn, responses} end end @@ -2237,7 +2250,7 @@ defmodule Mint.HTTP2 do ) else callback = &decode_push_promise_headers_and_add_response(&1, &2, &3, &4, promised_stream_id) - conn = put_in(conn.headers_being_processed, {stream_id, hbf, callback}) + conn = start_headers_being_processed(conn, stream_id, hbf, callback) {conn, responses} end end @@ -2392,18 +2405,53 @@ defmodule Mint.HTTP2 do assert_stream_in_state(conn, stream, [:open, :half_closed_local, :reserved_remote]) end - {^stream_id, hbf_acc, callback} = conn.headers_being_processed + {^stream_id, hbf_acc, callback, acc_size} = conn.headers_being_processed if flag_set?(flags, :continuation, :end_headers) do hbf = IO.iodata_to_binary([hbf_acc, hbf_chunk]) conn = put_in(conn.headers_being_processed, nil) callback.(conn, responses, hbf, stream) else - conn = put_in(conn.headers_being_processed, {stream_id, [hbf_acc, hbf_chunk], callback}) + new_size = acc_size + byte_size(hbf_chunk) + conn = assert_header_block_within_max_size(conn, new_size) + + conn = + put_in( + conn.headers_being_processed, + {stream_id, [hbf_acc, hbf_chunk], callback, new_size} + ) + {conn, responses} end end + defp start_headers_being_processed(conn, stream_id, hbf, callback) do + hbf_size = byte_size(hbf) + conn = assert_header_block_within_max_size(conn, hbf_size) + put_in(conn.headers_being_processed, {stream_id, hbf, callback, hbf_size}) + end + + # The header block accumulated from a HEADERS frame and its trailing + # CONTINUATION frames is buffered (in compressed form) until END_HEADERS + # arrives. A server can withhold END_HEADERS and stream CONTINUATION frames + # indefinitely, so the buffered size is bounded by the locally advertised + # SETTINGS_MAX_HEADER_LIST_SIZE. The compressed accumulator is never larger + # than the uncompressed header list it decodes to, so this never rejects a + # header block that fits within the advertised limit. + defp assert_header_block_within_max_size(conn, size) do + case conn.client_settings.max_header_list_size do + :infinity -> + conn + + max_size when size > max_size -> + debug_data = "header block exceeds SETTINGS_MAX_HEADER_LIST_SIZE of #{max_size} bytes" + send_connection_error!(conn, :protocol_error, debug_data) + + _max_size -> + conn + end + end + ## General helpers defp send_connection_error!(conn, error_code, debug_data) do
test/mint/http2/conn_test.exs+67 −0 modified@@ -963,6 +963,73 @@ defmodule Mint.HTTP2Test do end) end + @tag connect_options: [client_settings: [max_header_list_size: 1_000]] + test "a flood of CONTINUATION frames past max_header_list_size is a connection error", + %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + # Each CONTINUATION is individually under the limit, but together they + # accumulate past the advertised SETTINGS_MAX_HEADER_LIST_SIZE. The client + # must refuse to buffer the header block without bound rather than growing + # `headers_being_processed` until it runs out of memory. + chunk = :binary.copy(<<0>>, 400) + + assert {:error, %HTTP2{} = conn, error, []} = + stream_frames(conn, [ + headers(stream_id: stream_id, hbf: "", flags: set_flags(:headers, [])), + continuation( + stream_id: stream_id, + hbf: chunk, + flags: set_flags(:continuation, []) + ), + continuation( + stream_id: stream_id, + hbf: chunk, + flags: set_flags(:continuation, []) + ), + continuation( + stream_id: stream_id, + hbf: chunk, + flags: set_flags(:continuation, []) + ) + ]) + + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "SETTINGS_MAX_HEADER_LIST_SIZE" + + assert_recv_frames [goaway(error_code: :protocol_error)] + + refute HTTP2.open?(conn) + end + + @tag connect_options: [client_settings: [max_header_list_size: 1_000]] + test "a single oversized HEADERS fragment past max_header_list_size is a connection error", + %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + oversized_hbf = :binary.copy(<<0>>, 2_000) + + assert {:error, %HTTP2{} = conn, error, []} = + stream_frames(conn, [ + headers(stream_id: stream_id, hbf: oversized_hbf, flags: set_flags(:headers, [])) + ]) + + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "SETTINGS_MAX_HEADER_LIST_SIZE" + + assert_recv_frames [goaway(error_code: :protocol_error)] + + refute HTTP2.open?(conn) + end + + test "client advertises a default SETTINGS_MAX_HEADER_LIST_SIZE", %{conn: conn} do + assert HTTP2.get_client_setting(conn, :max_header_list_size) == 256 * 1024 + end + test ":authority pseudo-header includes port", %{conn: conn} do {conn, _ref} = open_request(conn)
Vulnerability mechanics
Root cause
"The HTTP/2 client did not enforce a limit on the size of accumulated header blocks received from a server."
Attack vector
An attacker controls an HTTP/2 server and sends a HEADERS frame without the END_HEADERS flag, followed by an unbounded sequence of CONTINUATION frames. Each CONTINUATION frame can be up to the peer-advertised SETTINGS_MAX_FRAME_SIZE. This causes the client's `conn.headers_being_processed` buffer to grow indefinitely, leading to memory exhaustion and a denial-of-service condition [ref_id=2].
Affected code
The vulnerability lies within the HTTP/2 receive path in Mint, specifically in how `handle_headers/3` and `handle_continuation/3` process incoming frames. The `conn.headers_being_processed` field was used to accumulate header fragments without a size cap [ref_id=2]. The fix modifies `lib/mint/http2.ex` to introduce `start_headers_being_processed/4` and `assert_header_block_within_max_size/2` to enforce the `SETTINGS_MAX_HEADER_LIST_SIZE`.
What the fix does
The patch introduces a limit to the size of accumulated header blocks received from a server. The client now enforces the `SETTINGS_MAX_HEADER_LIST_SIZE`, defaulting to 256 KB, by tracking the size of the compressed header block accumulator. If the accumulated size exceeds this limit, the connection is terminated with a PROTOCOL_ERROR [patch_id=4518409]. This prevents a malicious server from sending an unbounded chain of CONTINUATION frames and exhausting client memory.
Preconditions
- inputThe client must connect to an HTTP/2 server that is either malicious or compromised.
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.