From 09f7e9cdab4e5a5384ee174fb8348bf7fe4b3f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 17 Sep 2026 14:17:05 +0200 Subject: [PATCH 1/3] Validate HTTP/2 DATA frames against flow control padding and message order The flow-control accounting for a padded DATA frame counted the data and the padding but not the Pad Length byte, while RFC 9113 6.1 says the whole payload is flow controlled. The server's view of the receive window drifted by one byte per padded frame, and once the drift passed the WINDOW_UPDATE threshold the server saw an empty window that the client never refilled. A DATA frame arriving before the response HEADERS frame was delivered as a :data response with no preceding :status. RFC 9113 8.1 defines a response as HEADERS followed by DATA, and 8.1.1 makes a malformed response a stream error, so the stream is now reset with PROTOCOL_ERROR and the caller receives an error response. --- lib/mint/http2.ex | 52 +++++++++++------- test/mint/http2/conn_test.exs | 99 +++++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 23 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 63466b56..1082aeb3 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -1873,7 +1873,9 @@ defmodule Mint.HTTP2 do # Regardless of whether we have the stream or not, we need to abide by flow # control rules so we still refill the client window for the stream_id we got. - window_size_increment = byte_size(data) + byte_size(padding || "") + # RFC 9113 6.1: the whole payload is flow controlled, including the Pad Length + # byte and the padding. + window_size_increment = byte_size(data) + padding_size(padding) conn = if window_size_increment > 0 do @@ -1887,27 +1889,36 @@ defmodule Mint.HTTP2 do assert_stream_in_state(conn, stream, [:open, :half_closed_local]) body_size = stream.body_size + byte_size(data) - if stream.content_length && body_size > stream.content_length do - conn = close_stream!(conn, stream.id, :protocol_error) + cond do + # RFC 9113 8.1: a response starts with a HEADERS frame, so DATA before + # the final response headers is a malformed response. + not stream.received_first_headers? -> + conn = close_stream!(conn, stream.id, :protocol_error) + debug_data = "DATA frame received before the response HEADERS frame" + {conn, [{:error, stream.ref, wrap_error({:protocol_error, debug_data})} | responses]} - debug_data = - if stream.content_length == 0 do - "received DATA for a response that must not have content" - else - "the response body exceeds the content-length header value of " <> - "#{stream.content_length}" - end + stream.content_length && body_size > stream.content_length -> + conn = close_stream!(conn, stream.id, :protocol_error) - {conn, [{:error, stream.ref, wrap_error({:protocol_error, debug_data})} | responses]} - else - conn = put_in(conn.streams[stream.id].body_size, body_size) - responses = [{:data, stream.ref, data} | responses] + debug_data = + if stream.content_length == 0 do + "received DATA for a response that must not have content" + else + "the response body exceeds the content-length header value of " <> + "#{stream.content_length}" + end - if flag_set?(flags, :data, :end_stream) do - end_remote_stream(conn, stream, responses) - else - {conn, responses} - end + {conn, [{:error, stream.ref, wrap_error({:protocol_error, debug_data})} | responses]} + + true -> + conn = put_in(conn.streams[stream.id].body_size, body_size) + responses = [{:data, stream.ref, data} | responses] + + if flag_set?(flags, :data, :end_stream) do + end_remote_stream(conn, stream, responses) + else + {conn, responses} + end end :error -> @@ -1916,6 +1927,9 @@ defmodule Mint.HTTP2 do end end + defp padding_size(nil), do: 0 + defp padding_size(padding), do: byte_size(padding) + 1 + # Accounts for `data_size` bytes arriving on the connection and on # `stream_id`. Sends a WINDOW_UPDATE for either window only once its # remaining receive credit drops to `conn.receive_window_update_threshold`; diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 59cc975f..c66d2b0f 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -2165,6 +2165,41 @@ defmodule Mint.HTTP2Test do end describe "misbehaving server" do + test "sends DATA before the response HEADERS", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [data(stream_id: stream_id, data: "hello")]) + + assert [{:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "DATA frame received before the response HEADERS frame" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + + test "sends DATA after an interim response but before the final HEADERS", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "100"}], [:end_headers]}, + data(stream_id: stream_id, data: "hello") + ]) + + assert [{:status, ^ref, 100}, {:headers, ^ref, []}, {:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "DATA frame received before the response HEADERS frame" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + test "sends a frame with the wrong stream id", %{conn: conn} do {conn, _ref} = open_request(conn) @@ -2425,12 +2460,18 @@ defmodule Mint.HTTP2Test do assert {:ok, %HTTP2{} = _conn, responses} = stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}], [:end_headers]}, data(stream_id: stream_id, data: "", flags: set_flags(:data, [:end_stream])) ]) assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :no_error)] - assert responses == [{:data, ref, ""}, {:done, ref}] + assert responses == [ + {:status, ref, 200}, + {:headers, ref, []}, + {:data, ref, ""}, + {:done, ref} + ] end test "get_window_size/2 raises if the request is not found", %{conn: conn} do @@ -2606,6 +2647,47 @@ defmodule Mint.HTTP2Test do client_settings: [initial_window_size: 100_000] ] + test "padding of DATA frames counts towards the receive windows", %{conn: conn} do + {conn, _ref} = open_request(conn) + assert_recv_frames [headers(stream_id: stream_id)] + + # Each payload is 9_201 bytes: 1 byte of pad length, 9_000 bytes of data + # and 200 bytes of padding. 7 frames drop both windows to 35_593, below the + # 40_000 threshold, and the refill has to cover the whole payloads. + chunk = String.duplicate("a", 9_000) + padding = String.duplicate("p", 200) + + frames = for _ <- 1..7, do: data(stream_id: stream_id, data: chunk, padding: padding) + + assert {:ok, %HTTP2{} = conn, _responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}], [:end_headers]} | frames + ]) + + assert_recv_frames [ + window_update(stream_id: 0, window_size_increment: 64_407), + window_update(stream_id: ^stream_id, window_size_increment: 64_407) + ] + + assert conn.receive_window_remaining == 100_000 + assert conn.streams[stream_id].receive_window_remaining == 100_000 + end + + test "a PADDED DATA frame with no data and no padding counts its pad length byte", + %{conn: conn} do + {conn, _ref} = open_request(conn) + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, _responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}], [:end_headers]}, + data(stream_id: stream_id, data: "", padding: "") + ]) + + assert conn.receive_window_remaining == 99_999 + assert conn.streams[stream_id].receive_window_remaining == 99_999 + end + test "does not send WINDOW_UPDATE until remaining window drops below threshold", %{conn: conn} do {conn, _ref} = open_request(conn) @@ -2617,7 +2699,10 @@ defmodule Mint.HTTP2Test do frames = for _ <- 1..5, do: data(stream_id: stream_id, data: chunk) - assert {:ok, %HTTP2{} = _conn, _responses} = stream_frames(conn, frames) + assert {:ok, %HTTP2{} = _conn, _responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}], [:end_headers]} | frames + ]) assert_recv_frames [] end @@ -2634,7 +2719,10 @@ defmodule Mint.HTTP2Test do frames = for _ <- 1..7, do: data(stream_id: stream_id, data: chunk) - assert {:ok, %HTTP2{} = _conn, _responses} = stream_frames(conn, frames) + assert {:ok, %HTTP2{} = _conn, _responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}], [:end_headers]} | frames + ]) assert_recv_frames [ window_update(stream_id: 0, window_size_increment: 60_000), @@ -2663,7 +2751,10 @@ defmodule Mint.HTTP2Test do chunk = String.duplicate("a", 10_000) frames = for _ <- 1..46, do: data(stream_id: stream_id, data: chunk) - assert {:ok, %HTTP2{} = _conn, _responses} = stream_frames(conn, frames) + assert {:ok, %HTTP2{} = _conn, _responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}], [:end_headers]} | frames + ]) assert_recv_frames [ window_update(stream_id: 0, window_size_increment: 460_000), From e903641b9ced7d0c5d30811f4f19ca1770604b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 24 Sep 2026 13:10:54 +0200 Subject: [PATCH 2/3] Validate HTTP/2 frame padding and SETTINGS before using them A DATA, HEADERS or PUSH_PROMISE frame with the PADDED flag and an empty payload has no Pad Length field and is a FRAME_SIZE_ERROR (RFC 9113 6.1), a SETTINGS frame with the ACK flag and a payload is a FRAME_SIZE_ERROR (RFC 9113 6.5), and SETTINGS_ENABLE_PUSH is only valid as 0 or 1 and must not be set to 1 by a server (RFC 9113 6.5.2). All three used to be accepted. --- lib/mint/http2.ex | 5 ++++ lib/mint/http2/frame.ex | 47 ++++++++++++++++++++++++++++------ test/mint/http2/conn_test.exs | 26 +++++++++++++++++-- test/mint/http2/frame_test.exs | 20 ++++++++++++++- 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 1082aeb3..712258bd 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -2350,6 +2350,11 @@ defmodule Mint.HTTP2 do {:header_table_size, header_table_size}, conn -> update_in(conn.encode_table, &HPAX.resize(&1, header_table_size)) + # RFC 9113 6.5.2: a server must not set SETTINGS_ENABLE_PUSH to 1. + {:enable_push, true}, conn -> + debug_data = "SETTINGS_ENABLE_PUSH set to 1 by the server" + send_connection_error!(conn, :protocol_error, debug_data) + {:enable_push, enable_push?}, conn -> put_in(conn.server_settings.enable_push, enable_push?) diff --git a/lib/mint/http2/frame.ex b/lib/mint/http2/frame.ex index 7afa7c82..d0618ede 100644 --- a/lib/mint/http2/frame.ex +++ b/lib/mint/http2/frame.ex @@ -200,6 +200,12 @@ defmodule Mint.HTTP2.Frame do end # http://httpwg.org/specs/rfc7540.html#rfc.section.6.5 + # RFC 9113 6.5: a SETTINGS frame with the ACK flag set must have an empty payload. + defp decode_settings(flags, _stream_id, payload) + when is_flag_set(flags, unquote(@flags[:settings][:ack])) and byte_size(payload) > 0 do + throw({:mint, {:frame_size_error, :settings}}) + end + defp decode_settings(_flags, _stream_id, payload) when rem(byte_size(payload), 6) != 0 do throw({:mint, {:frame_size_error, :settings}}) end @@ -275,6 +281,12 @@ defmodule Mint.HTTP2.Frame do continuation(stream_id: stream_id, flags: flags, hbf: payload) end + # RFC 9113 6.1: a frame with the PADDED flag set always carries a Pad Length field. + defp decode_padding(frame, flags, <<>>) + when is_flag_set(flags, unquote(@flags[:data][:padded])) do + throw({:mint, {:frame_size_error, frame}}) + end + defp decode_padding(frame, flags, <> = payload) when is_flag_set(flags, unquote(@flags[:data][:padded])) do if pad_length >= byte_size(payload) do @@ -308,14 +320,33 @@ defmodule Mint.HTTP2.Frame do # ignore that setting. acc = case identifier do - 0x01 -> [{:header_table_size, value} | acc] - 0x02 -> [{:enable_push, value == 1} | acc] - 0x03 -> [{:max_concurrent_streams, value} | acc] - 0x04 -> [{:initial_window_size, value} | acc] - 0x05 -> [{:max_frame_size, value} | acc] - 0x06 -> [{:max_header_list_size, value} | acc] - 0x08 -> [{:enable_connect_protocol, value == 1} | acc] - _other -> acc + 0x01 -> + [{:header_table_size, value} | acc] + + # RFC 9113 6.5.2: SETTINGS_ENABLE_PUSH is only allowed to be 0 or 1. + 0x02 when value in [0, 1] -> + [{:enable_push, value == 1} | acc] + + 0x02 -> + throw({:mint, {:protocol_error, "SETTINGS_ENABLE_PUSH value #{value} is not 0 or 1"}}) + + 0x03 -> + [{:max_concurrent_streams, value} | acc] + + 0x04 -> + [{:initial_window_size, value} | acc] + + 0x05 -> + [{:max_frame_size, value} | acc] + + 0x06 -> + [{:max_header_list_size, value} | acc] + + 0x08 -> + [{:enable_connect_protocol, value == 1} | acc] + + _other -> + acc end decode_settings_params(rest, acc) diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index c66d2b0f..ddb712ba 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -3065,9 +3065,31 @@ defmodule Mint.HTTP2Test do test "server can send the :enable_push setting", %{conn: conn} do {:ok, %HTTP2{} = conn, []} = stream_frames(conn, [settings(params: [enable_push: false])]) assert HTTP2.get_server_setting(conn, :enable_push) == false + end - {:ok, %HTTP2{} = conn, []} = stream_frames(conn, [settings(params: [enable_push: true])]) - assert HTTP2.get_server_setting(conn, :enable_push) == true + test "if server sets :enable_push to 1, we send a connection error", %{conn: conn} do + assert {:error, %HTTP2{} = conn, error, []} = + stream_frames(conn, [settings(params: [enable_push: true])]) + + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "SETTINGS_ENABLE_PUSH set to 1 by the server" + + assert_recv_frames [goaway(error_code: :protocol_error)] + refute HTTP2.open?(conn) + end + + test "if server sends a SETTINGS ACK with a payload, we send a connection error", + %{conn: conn} do + data = + IO.iodata_to_binary(encode_raw(_settings = 0x04, _ack = 0x01, 0, <<0x03::16, 1::32>>)) + + assert {:error, %HTTP2{} = conn, error, []} = HTTP2.stream(conn, {:ssl, conn.socket, data}) + + assert_http2_error error, {:frame_size_error, debug_data} + assert debug_data =~ "error with size of frame: :settings" + + assert_recv_frames [goaway(error_code: :frame_size_error)] + refute HTTP2.open?(conn) end test "if server sends an invalid :initial_window_size, we send a connection error", diff --git a/test/mint/http2/frame_test.exs b/test/mint/http2/frame_test.exs index ffdd1143..4971c0a5 100644 --- a/test/mint/http2/frame_test.exs +++ b/test/mint/http2/frame_test.exs @@ -57,6 +57,14 @@ defmodule Mint.HTTP2.FrameTest do assert Frame.decode_next(encode_raw(0x00, 0x08, 3, payload)) == {:error, {:protocol_error, debug_data}} end + + test "with the PADDED flag and no payload" do + assert Frame.decode_next(encode_raw(0x00, 0x08, 3, <<>>)) == + {:error, {:frame_size_error, :data}} + + assert Frame.decode_next(encode_raw(0x01, 0x08, 3, <<>>)) == + {:error, {:frame_size_error, :headers}} + end end describe "HEADERS" do @@ -179,7 +187,7 @@ defmodule Mint.HTTP2.FrameTest do enable_connect_protocol: enable_connect_protocol ] - assert_round_trip settings(stream_id: 0, flags: 0x01, params: params) + assert_round_trip settings(stream_id: 0, flags: 0x00, params: params) end end @@ -187,6 +195,16 @@ defmodule Mint.HTTP2.FrameTest do assert Frame.decode_next(encode_raw(0x04, 0x00, 0, <<_not_multiple_of_6 = 3::8>>)) == {:error, {:frame_size_error, :settings}} end + + test "with the ACK flag and a payload" do + assert Frame.decode_next(encode_raw(0x04, 0x01, 0, <<0x03::16, 100::32>>)) == + {:error, {:frame_size_error, :settings}} + end + + test "with an ENABLE_PUSH value other than 0 or 1" do + assert Frame.decode_next(encode_raw(0x04, 0x00, 0, <<0x02::16, 2::32>>)) == + {:error, {:protocol_error, "SETTINGS_ENABLE_PUSH value 2 is not 0 or 1"}} + end end describe "PUSH_PROMISE" do From 5136294b197a2c5b8696d93f9b71ee3917b7215f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 24 Sep 2026 12:58:55 +0200 Subject: [PATCH 3/3] Accept HTTP/2 PRIORITY frames on idle streams and reject extension frames in header blocks PRIORITY frames on a client stream ID the client hadn't opened yet were treated as a connection error, but RFC 9113 5.1 allows PRIORITY on idle streams. They are now accepted. Extension frames received in the middle of a header block were ignored instead of being treated as the PROTOCOL_ERROR RFC 9113 5.5 requires. --- lib/mint/http2.ex | 19 ++++++++++--- test/mint/http2/conn_test.exs | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 712258bd..36906a27 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -1766,6 +1766,14 @@ defmodule Mint.HTTP2 do defp validate_frame(conn, unknown()) do # Unknown frames MUST be ignored: # https://datatracker.ietf.org/doc/html/rfc7540#section-4.1 + # RFC 9113 5.5: unless they appear in the middle of a header block. + if conn.headers_being_processed do + debug_data = + "headers are streaming but got an extension frame instead of a CONTINUATION frame" + + send_connection_error!(conn, :protocol_error, debug_data) + end + conn end @@ -1795,8 +1803,8 @@ defmodule Mint.HTTP2 do conn end - assert_frame_on_right_level(conn, elem(frame, 0), stream_id) - assert_stream_id_is_allowed(conn, stream_id) + assert_frame_on_right_level(conn, type, stream_id) + assert_stream_id_is_allowed(conn, type, stream_id) assert_frame_doesnt_interrupt_header_streaming(conn, frame) conn end @@ -1842,7 +1850,12 @@ defmodule Mint.HTTP2 do :ok end - defp assert_stream_id_is_allowed(conn, stream_id) do + # RFC 9113 5.1: PRIORITY is the only frame the server can send on an idle stream. + # Client streams are opened in order, so odd stream IDs from next_stream_id on are + # idle. + defp assert_stream_id_is_allowed(_conn, :priority, _stream_id), do: :ok + + defp assert_stream_id_is_allowed(conn, _frame, stream_id) do if Integer.is_odd(stream_id) and stream_id >= conn.next_stream_id do debug_data = "frame with stream ID #{inspect(stream_id)} has not been opened yet" send_connection_error!(conn, :protocol_error, debug_data) diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index ddb712ba..9cca2c67 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -2165,6 +2165,56 @@ defmodule Mint.HTTP2Test do end describe "misbehaving server" do + test "an extension frame in the middle of a header block is a connection error", + %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + hbf = server_encode_headers([{":status", "200"}]) + + data = + IO.iodata_to_binary([ + Frame.encode(headers(stream_id: stream_id, hbf: hbf, flags: 0x00)), + encode_raw(_extension_type = 0x20, 0x00, stream_id, "extension"), + Frame.encode( + continuation( + stream_id: stream_id, + hbf: "", + flags: set_flags(:continuation, [:end_headers]) + ) + ) + ]) + + assert {:error, %HTTP2{} = conn, error, []} = HTTP2.stream(conn, {:ssl, conn.socket, data}) + + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "got an extension frame instead of a CONTINUATION frame" + + assert_recv_frames [goaway(error_code: :protocol_error)] + + refute HTTP2.open?(conn) + end + + test "PRIORITY frames on idle streams are ignored", %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + priority_frames = + for idle_stream_id <- [stream_id + 2, 2] do + priority( + stream_id: idle_stream_id, + exclusive?: false, + stream_dependency: 0, + weight: 16 + ) + end + + assert {:ok, %HTTP2{} = conn, []} = stream_frames(conn, priority_frames) + assert HTTP2.open?(conn) + end + test "sends DATA before the response HEADERS", %{conn: conn} do {conn, ref} = open_request(conn)