From 4ff5da58f599fdcacc9b7878f0e5128f95d71aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 27 Sep 2026 16:03:31 +0200 Subject: [PATCH 1/7] Report incomplete HTTP/2 responses on RST_STREAM with NO_ERROR Streams are removed as soon as the server ends them with END_STREAM, so a RST_STREAM frame on a stream still being tracked means the response is incomplete. With the NO_ERROR code it was reported as {:done, ref}, both before any headers and in the middle of a body with a declared content-length. It now produces {:server_closed_request, :no_error}. --- lib/mint/http2.ex | 11 ++++------- test/mint/http2/conn_test.exs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 5c84e8d8..fcbdc359 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -2333,14 +2333,11 @@ defmodule Mint.HTTP2 do # If we receive RST_STREAM then the stream is definitely closed. # We won't send anything else on the stream so we can simply delete # it, so that if we get things like DATA on that stream we error out. + # Streams are removed as soon as the server ends them, so a RST_STREAM on a + # stream we still track means the response is incomplete, whatever the code. conn = delete_stream(conn, stream) - - if error_code == :no_error do - {conn, [{:done, stream.ref} | responses]} - else - error = wrap_error({:server_closed_request, error_code}) - {conn, [{:error, stream.ref, error} | responses]} - end + error = wrap_error({:server_closed_request, error_code}) + {conn, [{:error, stream.ref, error} | responses]} :error -> {conn, responses} diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 8b4310b2..36685875 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -359,6 +359,36 @@ defmodule Mint.HTTP2Test do end describe "closed streams" do + for phase <- [:before_the_headers, :during_the_body] do + test "RST_STREAM with NO_ERROR #{phase} is an error", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + conn = + if unquote(phase) == :during_the_body do + assert {:ok, %HTTP2{} = conn, + [{:status, ^ref, 200}, {:headers, ^ref, _}, {:data, ^ref, "x"}]} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}, {"content-length", "5"}], + [:end_headers]}, + data(stream_id: stream_id, data: "x") + ]) + + conn + else + conn + end + + assert {:ok, %HTTP2{} = conn, [{:error, ^ref, error}]} = + stream_frames(conn, [rst_stream(stream_id: stream_id, error_code: :no_error)]) + + assert_http2_error error, {:server_closed_request, :no_error} + refute Map.has_key?(conn.streams, stream_id) + assert HTTP2.open?(conn) + end + end + test "server closes a stream with RST_STREAM", %{conn: conn} do {conn, ref} = open_request(conn) From 5f4e7d70f5a72f770b5006e1845f666e7f9c52df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 27 Sep 2026 16:04:56 +0200 Subject: [PATCH 2/7] Reject HTTP/2 responses with connection-specific headers or unsupported status codes RFC 9113 8.2.2 makes a message containing connection, keep-alive, proxy-connection, transfer-encoding or upgrade malformed, which is a stream error. The te header is only allowed in requests, so it's rejected in responses and trailers as well. A :status of 101 is not supported in HTTP/2 (RFC 9113 8.6) and used to be delivered as an interim response, and :status values below 100 were accepted although HTTP/1 rejects them; both are now stream errors. --- lib/mint/http2.ex | 29 +++++++++++- test/mint/http2/conn_test.exs | 87 ++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index fcbdc359..8a2cd51d 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -2080,6 +2080,13 @@ defmodule Mint.HTTP2 do # https://httpwg.org/specs/rfc9113.html#HttpFraming [{":status", <> = status} | headers] -> cond do + # RFC 9113 8.6: HTTP/2 does not support the 101 status code. + status == "101" -> + conn = close_stream!(conn, stream.id, :protocol_error) + debug_data = "the 101 (Switching Protocols) status code is not supported in HTTP/2" + error = wrap_error({:protocol_error, debug_data}) + {conn, [{:error, stream.ref, error} | responses]} + end_stream? -> conn = close_stream!(conn, stream.id, :protocol_error) debug_data = "informational response (1xx) must not have the END_STREAM flag set" @@ -2217,14 +2224,32 @@ defmodule Mint.HTTP2 do cond do not valid_field_name?(name) -> {:error, {:invalid_header_name, name}} not valid_field_value?(value) -> {:error, {:invalid_header_value, name, value}} + connection_specific?(name) -> {:error, connection_specific_error(name)} true -> validate_response_headers(rest, trailers?, status?, true) end end - # RFC 9110 15: status-code = 3DIGIT - defp valid_status?(<>) when a in ?0..?9 and b in ?0..?9 and c in ?0..?9, do: true + # RFC 9110 15: status-code = 3DIGIT, with values in the range 100-999. + defp valid_status?(<>) when a in ?1..?9 and b in ?0..?9 and c in ?0..?9, do: true defp valid_status?(_other), do: false + # RFC 9113 8.2.2: a message with connection-specific header fields is malformed. + # "te" is only allowed in requests, with the "trailers" value. + @connection_specific_headers [ + "connection", + "keep-alive", + "proxy-connection", + "te", + "transfer-encoding", + "upgrade" + ] + + defp connection_specific?(name), do: name in @connection_specific_headers + + defp connection_specific_error(name) do + {:protocol_error, "connection-specific header #{inspect(name)} is not allowed in HTTP/2"} + end + # RFC 9113 8.2.1: a field name must not contain characters in 0x00-0x20, 0x41-0x5A # (uppercase letters) or 0x7F-0xFF, and only a pseudo-header field can contain a colon. defp valid_field_name?(<<>>), do: false diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 36685875..a2534f36 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -1261,7 +1261,7 @@ defmodule Mint.HTTP2Test do end describe "response header validation" do - for status <- ["abc", "", "+200", "2000", "20", "200 ", " 200", "1ab"] do + for status <- ["abc", "", "+200", "2000", "20", "200 ", " 200", "1ab", "000", "099"] do test "an invalid :status of #{inspect(status)} is a stream error", %{conn: conn} do {conn, ref} = open_request(conn) @@ -1280,6 +1280,89 @@ defmodule Mint.HTTP2Test do end end + for {name, value} <- [ + {"connection", "close"}, + {"keep-alive", "timeout=5"}, + {"proxy-connection", "keep-alive"}, + {"transfer-encoding", "chunked"}, + {"upgrade", "websocket"}, + {"te", "gzip"}, + {"te", "trailers"} + ] do + test "the connection-specific header #{name}: #{value} is a stream error", %{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"}, {unquote(name), unquote(value)}], + [:end_headers]} + ]) + + assert [{:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "connection-specific header #{inspect(unquote(name))}" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + end + + test "a connection-specific header in an informational response is a stream error", + %{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", "103"}, {"te", "trailers"}], [:end_headers]} + ]) + + assert [{:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "connection-specific header \"te\"" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + + test "a connection-specific header in trailers is a stream error", %{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]}, + {:headers, stream_id, [{"te", "trailers"}], [:end_headers, :end_stream]} + ]) + + assert [{:status, ^ref, 200}, {:headers, ^ref, []}, {:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "connection-specific header \"te\"" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + + test "a 101 status is a stream error", %{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", "101"}], [:end_headers]}]) + + assert [{:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "the 101 (Switching Protocols) status code is not supported in HTTP/2" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + for name <- ["Foo", "fo o", "", "foo:bar", "f\x7Fo", "f\xC3\xA4"] do test "an invalid header name #{inspect(name)} is a stream error", %{conn: conn} do {conn, ref} = open_request(conn) @@ -1724,7 +1807,7 @@ defmodule Mint.HTTP2Test do info_hbf = server_encode_headers([ - {":status", "101"}, + {":status", "102"}, {"x-info-header1", "this is an info"} ]) From acec936c95f16711e52e846b441073234e3e21f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 17 Sep 2026 14:17:02 +0200 Subject: [PATCH 3/7] Handle HTTP/2 PUSH_PROMISE frames on streams the client closed A PUSH_PROMISE on a stream that is no longer in the stream map, for example because the client cancelled the request before the server processed the RST_STREAM, raised {:stream_not_found, id} out of stream/2. The connection stayed open but the socket was never re-armed with active: :once, frames after the PUSH_PROMISE in the same message were dropped, and the header block was not decoded, so the next header block from the server failed with a compression error. RFC 9113 6.6 requires handling PUSH_PROMISE frames created before the RST_STREAM was processed. The header block is now decoded to keep the HPACK table in sync, the promised stream is reset with CANCEL and the frame is otherwise ignored. --- lib/mint/http2.ex | 69 +++++++++++++++++++++------------- test/mint/http2/conn_test.exs | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 25 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 8a2cd51d..7041c0b4 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -2526,8 +2526,15 @@ defmodule Mint.HTTP2 do assert_valid_promised_stream_id(conn, promised_stream_id) - stream = fetch_stream!(conn, stream_id) - assert_stream_in_state(conn, stream, [:open, :half_closed_local]) + # RFC 9113 6.6: the stream may already be closed because the client reset it + # before the server processed the RST_STREAM, so a missing stream is not an + # error. The header block still has to be decoded to keep the HPACK table in + # sync and the promised stream has to be reset. + stream = Map.get(conn.streams, stream_id) + + if stream do + assert_stream_in_state(conn, stream, [:open, :half_closed_local]) + end if flag_set?(flags, :push_promise, :end_headers) do decode_push_promise_headers_and_add_response( @@ -2562,27 +2569,39 @@ defmodule Mint.HTTP2 do # with the HEADERS that would open them. server_stream_count = conn.open_server_stream_count + conn.reserved_server_stream_count - 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, - method: promised_method(headers), - content_length: nil, - body_size: 0 - } - - 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]} + cond do + is_nil(stream) -> + log( + conn, + :debug, + "Received PUSH_PROMISE frame on closed stream, resetting the promised stream" + ) + + conn = reset_promised_stream(conn, promised_stream_id, :cancel) + {conn, responses} + + server_stream_count >= conn.client_settings.max_concurrent_streams -> + conn = reset_promised_stream(conn, promised_stream_id, :refused_stream) + {conn, responses} + + true -> + 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, + method: promised_method(headers), + content_length: nil, + body_size: 0 + } + + 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 @@ -2593,9 +2612,9 @@ defmodule Mint.HTTP2 do end end - defp refuse_promised_stream(conn, promised_stream_id) do + defp reset_promised_stream(conn, promised_stream_id, error_code) do if open?(conn) do - rst_stream_frame = rst_stream(stream_id: promised_stream_id, error_code: :refused_stream) + rst_stream_frame = rst_stream(stream_id: promised_stream_id, error_code: error_code) send!(conn, Frame.encode(rst_stream_frame)) else conn diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index a2534f36..0cbc2234 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -2062,6 +2062,76 @@ defmodule Mint.HTTP2Test do end describe "server pushes" do + test "a PUSH_PROMISE on a stream the client cancelled resets the promised stream", + %{conn: conn} do + {conn, ref} = open_request(conn) + {:ok, conn} = HTTP2.cancel_request(conn, ref) + + assert_recv_frames [ + headers(stream_id: stream_id), + rst_stream(stream_id: stream_id, error_code: :cancel) + ] + + hbf = server_encode_headers([{":method", "GET"}, {"x-promised", "value"}]) + + assert {:ok, %HTTP2{} = conn, []} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: hbf, + promised_stream_id: 2, + flags: set_flags(:push_promise, [:end_headers]) + ), + ping(opaque_data: <<0::64>>) + ]) + + assert HTTP2.open?(conn) + + assert_recv_frames [ + rst_stream(stream_id: 2, error_code: :cancel), + ping(opaque_data: <<0::64>>) + ] + + # The header block was decoded, so the HPACK table is still in sync. + {conn, ref} = open_request(conn) + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{}, responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}, {"x-promised", "value"}], + [:end_headers, :end_stream]} + ]) + + assert [{:status, ^ref, 200}, {:headers, ^ref, [{"x-promised", "value"}]}, {:done, ^ref}] = + responses + end + + test "a PUSH_PROMISE with CONTINUATIONs on a stream the client cancelled resets the promised stream", + %{conn: conn} do + {conn, ref} = open_request(conn) + {:ok, conn} = HTTP2.cancel_request(conn, ref) + + assert_recv_frames [ + headers(stream_id: stream_id), + rst_stream(stream_id: stream_id, error_code: :cancel) + ] + + <> = server_encode_headers([{":method", "GET"}, {"a", "b"}]) + + assert {:ok, %HTTP2{} = conn, []} = + stream_frames(conn, [ + push_promise(stream_id: stream_id, hbf: hbf1, promised_stream_id: 2), + continuation( + stream_id: stream_id, + hbf: hbf2, + flags: set_flags(:continuation, [:end_headers]) + ) + ]) + + assert HTTP2.open?(conn) + assert_recv_frames [rst_stream(stream_id: 2, error_code: :cancel)] + end + test "a PUSH_PROMISE frame and a few CONTINUATION frames are received", %{conn: conn} do promised_stream_id = 4 From 79c63e6093b6b4d86dc0ee6bb80ccf2ca74429ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 19 Sep 2026 19:29:41 +0200 Subject: [PATCH 4/7] Validate HTTP/2 PUSH_PROMISE streams and promised requests Promised stream identifiers were only checked for being even and unused, so a server could promise stream 0, promise identifiers lower than an earlier promise or reuse the identifier of a reset stream, and it could send PUSH_PROMISE on a stream it initiated itself. RFC 9113 5.1.1 and 8.4 make all of these connection errors. The highest promised identifier is now tracked and also reported as the last stream identifier in the GOAWAY frame sent on a connection error, which was hard-coded to 2. The promised request headers were delivered without validation. They now need non-empty :method, :scheme, :authority and :path pseudo-headers before any regular field, a :path starting with "/", a safe and cacheable method, no content, no connection-specific fields other than "te: trailers", and field names and values following the same rules as response headers (RFC 9113 8.2.2, 8.3.1, 8.4 and 8.4.1). A promise that fails is reset with PROTOCOL_ERROR. GOAWAY marked pushed streams above the last stream identifier as unprocessed and dropped their responses, but that identifier only covers client-initiated streams (RFC 9113 6.8). --- lib/mint/http2.ex | 124 +++++++++++++-- test/mint/http2/conn_test.exs | 288 ++++++++++++++++++++++++++++++++-- 2 files changed, 393 insertions(+), 19 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 7041c0b4..3a5fc2c8 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -191,6 +191,9 @@ defmodule Mint.HTTP2 do # Fields of the connection. buffer: "", + # Highest stream ID the server has promised through PUSH_PROMISE. Promised IDs must + # increase, and it is the last server-initiated stream reported in GOAWAY frames. + last_promised_stream_id: 0, # `send_window_size` is the client *send* window for the connection # — how much request-body data we're allowed to send to the server # before it refills the window with a WINDOW_UPDATE frame. @@ -390,8 +393,9 @@ defmodule Mint.HTTP2 do When this error is returned, it means that the server hasn't processed the request at all, so it's safe to retry the given request on a different or new connection. - * `{:server_closed_request, error_code}` - when the server closes the request. - `error_code` is the reason why the request was closed. + * `{:server_closed_request, error_code}` - when the server closes the request before + the response is complete. `error_code` is the reason why the request was closed, + which can be `:no_error` when the server ends a response early. * `{:server_closed_connection, reason, debug_data}` - when the server closes the connection gracefully or because of an error. In HTTP/2, this corresponds to a `GOAWAY` frame. @@ -2524,7 +2528,8 @@ defmodule Mint.HTTP2 do hbf: hbf ) = frame - assert_valid_promised_stream_id(conn, promised_stream_id) + assert_valid_push_promise_stream_ids(conn, stream_id, promised_stream_id) + conn = put_in(conn.last_promised_stream_id, promised_stream_id) # RFC 9113 6.6: the stream may already be closed because the client reset it # before the server processed the RST_STREAM, so a missing stream is not an @@ -2580,6 +2585,11 @@ defmodule Mint.HTTP2 do conn = reset_promised_stream(conn, promised_stream_id, :cancel) {conn, responses} + debug_data = promised_headers_error(headers) -> + log(conn, :debug, "Resetting promised stream #{promised_stream_id}: #{debug_data}") + conn = reset_promised_stream(conn, promised_stream_id, :protocol_error) + {conn, responses} + server_stream_count >= conn.client_settings.max_concurrent_streams -> conn = reset_promised_stream(conn, promised_stream_id, :refused_stream) {conn, responses} @@ -2621,16 +2631,24 @@ defmodule Mint.HTTP2 do end end - defp assert_valid_promised_stream_id(conn, promised_stream_id) do + # RFC 9113 8.4: PUSH_PROMISE frames are only allowed on client-initiated streams. + # RFC 9113 5.1.1: server-initiated streams have even identifiers, 0 is reserved for + # the connection, and the identifier of a new stream must be greater than all the + # streams the server has already opened or reserved. + defp assert_valid_push_promise_stream_ids(conn, stream_id, promised_stream_id) do cond do - not is_integer(promised_stream_id) or Integer.is_odd(promised_stream_id) -> + Integer.is_even(stream_id) -> + debug_data = "PUSH_PROMISE frame on server-initiated stream #{stream_id}" + send_connection_error!(conn, :protocol_error, debug_data) + + promised_stream_id == 0 or Integer.is_odd(promised_stream_id) -> debug_data = "invalid promised stream ID: #{inspect(promised_stream_id)}" send_connection_error!(conn, :protocol_error, debug_data) - Map.has_key?(conn.streams, promised_stream_id) -> + promised_stream_id <= conn.last_promised_stream_id -> debug_data = - "stream with ID #{inspect(promised_stream_id)} already exists and can't be " <> - "reserved by the server" + "promised stream ID #{promised_stream_id} is not greater than the last " <> + "promised stream ID #{conn.last_promised_stream_id}" send_connection_error!(conn, :protocol_error, debug_data) @@ -2639,6 +2657,84 @@ defmodule Mint.HTTP2 do end end + @promised_pseudo_headers [":method", ":scheme", ":authority", ":path"] + + # RFC 9113 8.4.1: a promised request must be cacheable and safe and must not have + # content, and RFC 9113 8.4 and 8.3.1 require the :method, :scheme, :authority and + # :path pseudo-headers. Field names and values follow the same rules as response + # headers, except that "te" is allowed with the "trailers" value (RFC 9113 8.2.2). + defp promised_headers_error(headers) do + case validate_promised_fields(headers, _pseudo = %{}, _regular? = false) do + {:error, debug_data} -> + debug_data + + {:ok, pseudo} -> + cond do + not Map.has_key?(pseudo, ":method") -> + "missing :method pseudo-header in promised request" + + pseudo[":scheme"] in [nil, ""] -> + "missing or empty :scheme pseudo-header in promised request" + + pseudo[":authority"] in [nil, ""] -> + "missing or empty :authority pseudo-header in promised request" + + not String.starts_with?(pseudo[":path"] || "", "/") -> + "missing or invalid :path pseudo-header in promised request" + + pseudo[":method"] not in ["GET", "HEAD"] -> + "promised request method #{inspect(pseudo[":method"])} is not safe and cacheable" + + true -> + case content_length(headers) do + {:ok, content_length} when content_length in [nil, 0] -> nil + {:ok, _content_length} -> "promised request must not have content" + {:error, _reason} -> "invalid content-length header in promised request" + end + end + end + end + + defp validate_promised_fields([], pseudo, _regular?), do: {:ok, pseudo} + + defp validate_promised_fields([{":" <> _ = name, value} | rest], pseudo, regular?) do + cond do + regular? -> + {:error, "pseudo-header #{inspect(name)} must appear before regular header fields"} + + name not in @promised_pseudo_headers -> + {:error, "undefined pseudo-header #{inspect(name)} in promised request"} + + Map.has_key?(pseudo, name) -> + {:error, "the #{name} pseudo-header appears more than once"} + + not valid_field_value?(value) -> + {:error, "invalid value for pseudo-header #{inspect(name)}"} + + true -> + validate_promised_fields(rest, Map.put(pseudo, name, value), regular?) + end + end + + defp validate_promised_fields([{name, value} | rest], pseudo, _regular?) do + cond do + not valid_field_name?(name) -> + {:error, "invalid header name #{inspect(name)}"} + + not valid_field_value?(value) -> + {:error, "invalid value for header #{inspect(name)}"} + + name == "te" and String.downcase(value, :ascii) == "trailers" -> + validate_promised_fields(rest, pseudo, true) + + connection_specific?(name) -> + {:error, elem(connection_specific_error(name), 1)} + + true -> + validate_promised_fields(rest, pseudo, true) + end + end + # PING defp handle_ping(conn, Frame.ping() = frame, responses) do @@ -2680,9 +2776,12 @@ defmodule Mint.HTTP2 do # We gather all the unprocessed requests and form {:error, _, _} tuples for each one. # At the same time, we delete all the unprocessed requests from the stream set. + # RFC 9113 6.8: the last stream ID only covers streams initiated by the client, so + # server-initiated (even) streams are never unprocessed. {unprocessed_request_responses, conn} = Enum.flat_map_reduce(conn.streams, conn, fn - {stream_id, _stream}, conn_acc when stream_id <= last_stream_id -> + {stream_id, _stream}, conn_acc + when Integer.is_even(stream_id) or stream_id <= last_stream_id -> {[], conn_acc} {_stream_id, stream}, conn_acc -> @@ -2814,7 +2913,12 @@ defmodule Mint.HTTP2 do defp send_connection_error!(conn, error_code, debug_data) do frame = - goaway(stream_id: 0, last_stream_id: 2, error_code: error_code, debug_data: debug_data) + goaway( + stream_id: 0, + last_stream_id: conn.last_promised_stream_id, + error_code: error_code, + debug_data: debug_data + ) # Try to send the GOAWAY frame and close connection. # If the frame fails to send, we still want to set the close diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 0cbc2234..fabe0482 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -1943,7 +1943,7 @@ defmodule Mint.HTTP2Test do assert_recv_frames [headers(stream_id: stream_id)] - promised_hbf = server_encode_headers([{":method", "GET"}]) + promised_hbf = server_encode_headers(promised_headers()) hbf1 = server_encode_headers([{":status", "200"}]) hbf2 = server_encode_headers([{":status", "200"}]) trailer_hbf = server_encode_headers([{"x-trailer", "some value"}]) @@ -1975,7 +1975,7 @@ defmodule Mint.HTTP2Test do ]) assert [ - {:push_promise, ^ref, promised_ref, [{":method", "GET"}]}, + {:push_promise, ^ref, promised_ref, promised_headers}, {:status, ^ref, 200}, {:headers, ^ref, []}, {:done, ^ref}, @@ -1985,6 +1985,7 @@ defmodule Mint.HTTP2Test do {:done, promised_ref} ] = responses + assert promised_headers == promised_headers() assert HTTP2.open?(conn) end @@ -2062,6 +2063,247 @@ defmodule Mint.HTTP2Test do end describe "server pushes" do + for {variant, fields} <- [ + missing_scheme: [{":method", "GET"}, {":authority", "localhost"}, {":path", "/"}], + empty_scheme: [ + {":method", "GET"}, + {":scheme", ""}, + {":authority", "localhost"}, + {":path", "/"} + ], + missing_authority: [{":method", "GET"}, {":scheme", "https"}, {":path", "/"}], + empty_authority: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", ""}, + {":path", "/"} + ], + empty_path: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", ""} + ], + asterisk_path: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "*"} + ], + relative_path: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "style.css"} + ], + unsafe_method: [ + {":method", "POST"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/"} + ], + duplicate_method: [ + {":method", "GET"}, + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/"} + ], + uppercase_name: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/"}, + {"Foo", "bar"} + ], + control_in_value: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/"}, + {"foo", "a\nb"} + ], + connection_header: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/"}, + {"connection", "keep-alive"} + ], + te_other_than_trailers: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/"}, + {"te", "gzip"} + ], + content: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/"}, + {"content-length", "1"} + ], + invalid_content_length: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/"}, + {"content-length", "zero"} + ] + ] do + test "a PUSH_PROMISE with #{variant} in the promised request resets the promised stream", + %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, []} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(unquote(fields)), + promised_stream_id: 2, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + assert_recv_frames [rst_stream(stream_id: 2, error_code: :protocol_error)] + refute Map.has_key?(conn.streams, 2) + assert HTTP2.open?(conn) + end + end + + for {variant, fields} <- [ + te_trailers: [{"te", "trailers"}], + mixed_case_te_trailers: [{"te", "Trailers"}], + zero_content_length: [{"content-length", "00"}] + ] do + test "a PUSH_PROMISE with #{variant} in the promised request is accepted", + %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + promised_headers = promised_headers() ++ unquote(fields) + + assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, _promised_ref, ^promised_headers}]} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers), + promised_stream_id: 2, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + refute_receive {:ssl, _socket, _data}, 100 + assert HTTP2.open?(conn) + end + end + + for {name, promised_stream_id} <- [ + {"zero", 0}, + {"odd", 3}, + {"not greater than the previous promised ID", 2} + ] do + test "a PUSH_PROMISE with a #{name} promised stream ID is a connection error", + %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, [{:push_promise, _, _, _}]} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers()), + promised_stream_id: 4, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + assert {:error, %HTTP2{} = conn, error, []} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers()), + promised_stream_id: unquote(promised_stream_id), + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "promised stream ID" + assert_recv_frames [goaway(last_stream_id: 4, error_code: :protocol_error)] + refute HTTP2.open?(conn) + end + end + + test "a PUSH_PROMISE on a server-initiated stream is a connection error", %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, [{:push_promise, _, promised_ref, _}]} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers()), + promised_stream_id: 2, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + assert {:ok, %HTTP2{} = conn, + [{:status, ^promised_ref, 200}, {:headers, ^promised_ref, []}]} = + stream_frames(conn, [{:headers, 2, [{":status", "200"}], [:end_headers]}]) + + assert {:error, %HTTP2{} = conn, error, []} = + stream_frames(conn, [ + push_promise( + stream_id: 2, + hbf: server_encode_headers(promised_headers()), + promised_stream_id: 4, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "PUSH_PROMISE frame on server-initiated stream 2" + refute HTTP2.open?(conn) + end + + test "a pushed response in flight is not affected by a GOAWAY", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, promised_ref, _}]} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers()), + promised_stream_id: 4, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + assert {:ok, %HTTP2{} = conn, + [{:status, ^promised_ref, 200}, {:headers, ^promised_ref, []}]} = + stream_frames(conn, [{:headers, 4, [{":status", "200"}], [:end_headers]}]) + + assert {:ok, %HTTP2{} = conn, []} = + stream_frames(conn, [ + goaway(last_stream_id: stream_id, error_code: :no_error, debug_data: "") + ]) + + assert {:ok, %HTTP2{}, [{:data, ^promised_ref, "body"}, {:done, ^promised_ref}]} = + stream_frames(conn, [ + data(stream_id: 4, data: "body", flags: set_flags(:data, [:end_stream])) + ]) + end + test "a PUSH_PROMISE on a stream the client cancelled resets the promised stream", %{conn: conn} do {conn, ref} = open_request(conn) @@ -2141,7 +2383,7 @@ defmodule Mint.HTTP2Test do assert_recv_frames [headers(stream_id: stream_id)] # Promised headers. - headers = [{":method", "GET"}, {"foo", "bar"}, {"baz", "bong"}] + headers = promised_headers() ++ [{"foo", "bar"}, {"baz", "bong"}] <> = server_encode_headers(headers) @@ -2176,7 +2418,7 @@ defmodule Mint.HTTP2Test do ] = responses assert is_reference(promised_ref) - assert headers == [{":method", "GET"}, {"foo", "bar"}, {"baz", "bong"}] + assert headers == promised_headers() ++ [{"foo", "bar"}, {"baz", "bong"}] assert {:ok, %HTTP2{} = conn, responses} = stream_frames(conn, [ @@ -2209,7 +2451,7 @@ defmodule Mint.HTTP2Test do assert_recv_frames [headers(stream_id: stream_id)] - hbf = server_encode_headers([{":method", "GET"}]) + hbf = server_encode_headers(promised_headers()) assert {:error, %HTTP2{} = conn, error, []} = stream_frames(conn, [ @@ -2234,7 +2476,7 @@ defmodule Mint.HTTP2Test do assert_recv_frames [headers(stream_id: stream_id)] - promised_headers_hbf = server_encode_headers([{":method", "GET"}]) + promised_headers_hbf = server_encode_headers(promised_headers()) normal_headers_hbf = server_encode_headers([{":status", "200"}]) assert {:error, %HTTP2{} = conn, error, _responses} = @@ -2259,7 +2501,9 @@ defmodule Mint.HTTP2Test do ]) assert_http2_error error, {:protocol_error, debug_data} - assert debug_data =~ "stream with ID 4 already exists and can't be reserved by the server" + + assert debug_data =~ + "promised stream ID 4 is not greater than the last promised stream ID 4" refute HTTP2.open?(conn) end @@ -2271,7 +2515,7 @@ defmodule Mint.HTTP2Test do assert_recv_frames [headers(stream_id: stream_id)] - promised_headers_hbf = server_encode_headers([{":method", "GET"}]) + promised_headers_hbf = server_encode_headers(promised_headers()) normal_headers_hbf = server_encode_headers([{":status", "200"}]) assert {:ok, %HTTP2{} = conn, responses} = @@ -2320,7 +2564,7 @@ defmodule Mint.HTTP2Test do assert_recv_frames [headers(stream_id: stream_id)] - promised_headers_hbf = server_encode_headers([{":method", "GET"}]) + promised_headers_hbf = server_encode_headers(promised_headers()) # 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 @@ -2362,6 +2606,28 @@ defmodule Mint.HTTP2Test do end describe "misbehaving server" do + test "the GOAWAY sent on a connection error carries the last promised stream ID", + %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, [{:push_promise, _, _, _}]} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers()), + promised_stream_id: 6, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + data = IO.iodata_to_binary(encode_raw(_ping = 0x06, 0x00, 3, <<0::64>>)) + assert {:error, %HTTP2{}, _error, []} = HTTP2.stream(conn, {:ssl, conn.socket, data}) + + assert_recv_frames [goaway(last_stream_id: 6, error_code: :protocol_error)] + end + test "an extension frame in the middle of a header block is a connection error", %{conn: conn} do {conn, _ref} = open_request(conn) @@ -4086,6 +4352,10 @@ defmodule Mint.HTTP2Test do headers end + defp promised_headers do + [{":method", "GET"}, {":scheme", "https"}, {":authority", "localhost"}, {":path", "/"}] + end + defp open_request(conn, body \\ nil) do assert {:ok, %HTTP2{} = conn, ref} = HTTP2.request(conn, "GET", "/", [], body) assert is_reference(ref) From f36dbfbbe9c101b55f5fdb09b9ff2d832fc9e494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 19 Sep 2026 19:52:26 +0200 Subject: [PATCH 5/7] Open promised HTTP/2 streams on interim responses An interim (1xx) response on a stream reserved by a PUSH_PROMISE closed the connection with a protocol error because the stream was still in the reserved state. RFC 9113 5.1 moves a reserved (remote) stream to half-closed (local) on any HEADERS frame, so the stream is now opened, and counted against the concurrency limit, before the interim response is delivered. Opening the stream first also means a pushed response that ends with its HEADERS frame no longer makes the client send a RST_STREAM on the closed stream (RFC 9113 5.1). A pushed response refused at that point because it would exceed the client's max_concurrent_streams setting was reset without any response for the promised request ref. It now returns a :too_many_concurrent_requests error for that ref. --- lib/mint/http2.ex | 97 +++++++++++++++++++---------------- test/mint/http2/conn_test.exs | 96 ++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 45 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 3a5fc2c8..ce4fc26a 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -351,7 +351,9 @@ defmodule Mint.HTTP2 do * `:too_many_concurrent_requests` - when the maximum number of concurrent requests allowed by the server is reached. To find out what this limit is, use `get_setting/2` - with the `:max_concurrent_streams` setting name. + with the `:max_concurrent_streams` setting name. It's also returned for a promised + request whose pushed response is refused because it would exceed the client's + `:max_concurrent_streams` setting. * `{:max_header_list_size_exceeded, size, max_size}` - when the maximum size of the header list is reached. `size` is the actual value of the header list size, @@ -2099,53 +2101,42 @@ defmodule Mint.HTTP2 do {conn, responses} true -> - assert_stream_in_state(conn, stream, [:open, :half_closed_local]) - status = String.to_integer(status) - headers = join_cookie_headers(headers) - new_responses = [{:headers, ref, headers}, {:status, ref, status} | responses] - {conn, new_responses} + case open_promised_stream(conn, stream) do + {:ok, conn} -> + status = String.to_integer(status) + headers = join_cookie_headers(headers) + new_responses = [{:headers, ref, headers}, {:status, ref, status} | responses] + {conn, new_responses} + + {:refused, conn} -> + error = wrap_error(:too_many_concurrent_requests) + {conn, [{:error, ref, error} | responses]} + end end [{":status", status} | headers] when not received_first_headers? -> status = String.to_integer(status) headers = join_cookie_headers(headers) - case response_content_length(stream, status, headers) do - {:ok, content_length} -> - conn = - update_in( - conn.streams[stream.id], - &%{&1 | received_first_headers?: true, content_length: content_length} - ) - - new_responses = [{:headers, ref, headers}, {:status, ref, status} | responses] - - cond do - # :reserved_remote means that this was a promised stream. As soon as headers come, - # the stream goes in the :half_closed_local state (unless it's not allowed because - # of the client's max concurrent streams limit, or END_STREAM is set). - stream.state == :reserved_remote -> - cond do - conn.open_server_stream_count >= conn.client_settings.max_concurrent_streams -> - conn = close_stream!(conn, stream.id, :refused_stream) - {conn, responses} - - end_stream? -> - end_remote_stream(conn, stream, new_responses) - - 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 - - end_stream? -> - end_remote_stream(conn, stream, new_responses) - - true -> - {conn, new_responses} - end + with {:ok, content_length} <- response_content_length(stream, status, headers), + {:ok, conn} <- open_promised_stream(conn, stream) do + conn = + update_in( + conn.streams[stream.id], + &%{&1 | received_first_headers?: true, content_length: content_length} + ) + + new_responses = [{:headers, ref, headers}, {:status, ref, status} | responses] + + if end_stream? do + end_remote_stream(conn, stream, new_responses) + else + {conn, new_responses} + end + else + {:refused, conn} -> + error = wrap_error(:too_many_concurrent_requests) + {conn, [{:error, ref, error} | responses]} {:error, reason} -> conn = close_stream!(conn, stream.id, :protocol_error) @@ -2178,6 +2169,21 @@ defmodule Mint.HTTP2 do end end + # RFC 9113 5.1: HEADERS frames move a stream reserved by a PUSH_PROMISE to the + # half-closed (local) state, where it counts against the client's concurrency + # limit. Streams that don't fit within the limit are refused. + defp open_promised_stream(conn, %{state: :reserved_remote} = stream) do + if conn.open_server_stream_count >= conn.client_settings.max_concurrent_streams do + {:refused, close_stream!(conn, stream.id, :refused_stream)} + else + conn = update_in(conn.open_server_stream_count, &(&1 + 1)) + conn = update_in(conn.reserved_server_stream_count, &(&1 - 1)) + {:ok, put_in(conn.streams[stream.id].state, :half_closed_local)} + end + end + + defp open_promised_stream(conn, _stream), do: {:ok, conn} + defp decode_hbf(conn, hbf) do case HPAX.decode(hbf, conn.decode_table) do {:ok, headers, decode_table} -> @@ -3032,9 +3038,10 @@ defmodule Mint.HTTP2 do end def format_error(:too_many_concurrent_requests) do - "the number of max concurrent HTTP/2 requests supported by the server has been reached. " <> - "Use Mint.HTTP2.get_server_setting/2 with the :max_concurrent_streams setting name " <> - "to find out the maximum number of concurrent requests supported by the server." + "the maximum number of concurrent HTTP/2 streams has been reached. For requests, use " <> + "Mint.HTTP2.get_server_setting/2 with the :max_concurrent_streams setting name to find " <> + "out the limit supported by the server. For pushed responses, the limit is the " <> + ":max_concurrent_streams client setting." end def format_error({:max_header_list_size_exceeded, size, max_size}) do diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index fabe0482..296e6485 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -2063,6 +2063,68 @@ defmodule Mint.HTTP2Test do end describe "server pushes" do + test "interim responses on a promised stream", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, promised_ref, _}]} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers()), + promised_stream_id: 2, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, 2, [{":status", "103"}, {"link", "; rel=preload"}], + [:end_headers]}, + {:headers, 2, [{":status", "200"}], [:end_headers, :end_stream]} + ]) + + assert [ + {:status, ^promised_ref, 103}, + {:headers, ^promised_ref, [{"link", "; rel=preload"}]}, + {:status, ^promised_ref, 200}, + {:headers, ^promised_ref, []}, + {:done, ^promised_ref} + ] = responses + + refute_receive {:ssl, _socket, _data}, 100 + assert HTTP2.open_request_count(conn) == 1 + assert HTTP2.open?(conn) + end + + test "a pushed response ending with its HEADERS frame sends no RST_STREAM", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers()), + promised_stream_id: 2, + flags: set_flags(:push_promise, [:end_headers]) + ), + {:headers, 2, [{":status", "304"}], [:end_headers, :end_stream]} + ]) + + assert [ + {:push_promise, ^ref, promised_ref, _}, + {:status, promised_ref, 304}, + {:headers, promised_ref, []}, + {:done, promised_ref} + ] = responses + + refute_receive {:ssl, _socket, _data}, 100 + assert HTTP2.open_request_count(conn) == 1 + end + for {variant, fields} <- [ missing_scheme: [{":method", "GET"}, {":authority", "localhost"}, {":path", "/"}], empty_scheme: [ @@ -2557,6 +2619,40 @@ defmodule Mint.HTTP2Test do assert HTTP2.open?(conn) end + for {variant, status} <- [final: "200", interim: "103"] do + test "a promised stream refused at #{variant} HEADERS time returns an error", + %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, promised_ref, _}]} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers()), + promised_stream_id: 2, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + {:ok, conn} = HTTP2.put_settings(conn, max_concurrent_streams: 0) + assert_recv_frames [settings()] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + settings(flags: set_flags(:settings, [:ack]), params: []), + {:headers, 2, [{":status", unquote(status)}], [:end_headers]} + ]) + + assert [{:error, ^promised_ref, error}] = responses + assert_http2_error error, :too_many_concurrent_requests + assert_recv_frames [rst_stream(stream_id: 2, error_code: :refused_stream)] + refute Map.has_key?(conn.streams, 2) + assert HTTP2.open?(conn) + end + 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 From 8da6b6a64f53ea3e0b98965c5c6e93539ae928f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 19 Sep 2026 20:30:08 +0200 Subject: [PATCH 6/7] Track promised HTTP/2 streams by their request ref Streams reserved by a PUSH_PROMISE were added to the stream map but not to the ref-to-stream index, so functions taking the promised request ref treated it as unknown. cancel_request/2 returned {:ok, conn} without sending RST_STREAM, so there was no way to refuse a pushed response, get_window_size/2 raised ArgumentError, and set_window_size/3 returned :unknown_request_to_stream. The docs describe the promised ref as a request ref like any other. stream_request_body/3 with trailers now checks the stream state the way DATA does, so trailers for a promised request or for a request whose body has ended return :request_is_not_streaming instead of sending a HEADERS frame. Trailers also no longer count the request as open a second time in open_request_count/1. --- lib/mint/http2.ex | 29 +++++++++--- test/mint/http2/conn_test.exs | 83 +++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index ce4fc26a..758e614e 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -1415,6 +1415,13 @@ defmodule Mint.HTTP2 do end defp encode_stream_body_request_payload(conn, stream_id, {:eof, trailers}) do + stream = fetch_stream!(conn, stream_id) + + if stream.state != :open do + error = wrap_error(:request_is_not_streaming) + throw({:mint, conn, error}) + end + trailers = Headers.from_raw(trailers) if unallowed_trailer_header = Headers.find_unallowed_trailer(trailers) do @@ -1423,7 +1430,10 @@ defmodule Mint.HTTP2 do end trailer_headers = Headers.to_raw(trailers, _case_sensitive = false) - encode_headers(conn, stream_id, trailer_headers, [:end_headers, :end_stream]) + enabled_flags = [:end_headers, :end_stream] + {conn, payload} = encode_header_block(conn, stream_id, trailer_headers, enabled_flags) + conn = put_in(conn.streams[stream_id].state, :half_closed_local) + {conn, payload} end defp encode_stream_body_request_payload(conn, stream_id, iodata) do @@ -1445,12 +1455,7 @@ defmodule Mint.HTTP2 do end defp encode_headers(conn, stream_id, headers, enabled_flags) do - assert_headers_smaller_than_max_header_list_size(conn, headers) - - headers = Enum.map(headers, fn {name, value} -> {:store_name, name, value} end) - {hbf, conn} = get_and_update_in(conn.encode_table, &HPAX.encode(headers, &1)) - - payload = headers_to_encoded_frames(conn, stream_id, hbf, enabled_flags) + {conn, payload} = encode_header_block(conn, stream_id, headers, enabled_flags) stream_state = if :end_stream in enabled_flags, do: :half_closed_local, else: :open @@ -1460,6 +1465,15 @@ defmodule Mint.HTTP2 do {conn, payload} end + defp encode_header_block(conn, stream_id, headers, enabled_flags) do + assert_headers_smaller_than_max_header_list_size(conn, headers) + + headers = Enum.map(headers, fn {name, value} -> {:store_name, name, value} end) + {hbf, conn} = get_and_update_in(conn.encode_table, &HPAX.encode(headers, &1)) + + {conn, headers_to_encoded_frames(conn, stream_id, hbf, enabled_flags)} + end + defp assert_headers_smaller_than_max_header_list_size( %{server_settings: %{max_header_list_size: :infinity}}, _headers @@ -2615,6 +2629,7 @@ defmodule Mint.HTTP2 do } conn = put_in(conn.streams[promised_stream.id], promised_stream) + conn = put_in(conn.ref_to_stream_id[promised_stream.ref], promised_stream.id) conn = update_in(conn.reserved_server_stream_count, &(&1 + 1)) new_response = {:push_promise, stream.ref, promised_stream.ref, headers} {conn, [new_response | responses]} diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 296e6485..e95caecf 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -2098,6 +2098,33 @@ defmodule Mint.HTTP2Test do assert HTTP2.open?(conn) end + test "cancelling a promised request resets the promised stream", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, promised_ref, _}]} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers()), + promised_stream_id: 2, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + assert {:ok, %HTTP2{} = conn} = HTTP2.cancel_request(conn, promised_ref) + assert_recv_frames [rst_stream(stream_id: 2, error_code: :cancel)] + + assert {:ok, %HTTP2{} = conn, []} = + stream_frames(conn, [ + {:headers, 2, [{":status", "200"}], [:end_headers, :end_stream]} + ]) + + assert HTTP2.open?(conn) + assert HTTP2.open_request_count(conn) == 1 + end + test "a pushed response ending with its HEADERS frame sends no RST_STREAM", %{conn: conn} do {conn, ref} = open_request(conn) @@ -3975,6 +4002,62 @@ defmodule Mint.HTTP2Test do assert server_decode_headers(trailer_hbf1 <> trailer_hbf2) == trailer_headers end + test "trailers keep the open request count", %{conn: conn} do + {conn, ref} = open_request(conn, :stream) + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn} = + HTTP2.stream_request_body(conn, ref, {:eof, [{"x-trailer", "value"}]}) + + assert_recv_frames [headers(stream_id: ^stream_id)] + assert HTTP2.open_request_count(conn) == 1 + + assert {:ok, %HTTP2{} = conn, [{:status, ^ref, 200}, {:headers, ^ref, []}, {:done, ^ref}]} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}], [:end_headers, :end_stream]} + ]) + + assert HTTP2.open_request_count(conn) == 0 + end + + test "trailers on a request whose body has ended return an error", %{conn: conn} do + {conn, ref} = open_request(conn) + assert_recv_frames [headers(stream_id: _stream_id)] + + assert {:error, %HTTP2{} = conn, error} = + HTTP2.stream_request_body(conn, ref, {:eof, [{"x-trailer", "value"}]}) + + assert_http2_error error, :request_is_not_streaming + refute_receive {:ssl, _socket, _data}, 100 + assert HTTP2.open_request_count(conn) == 1 + end + + test "trailers on a promised request return an error", %{conn: conn} do + {conn, ref} = open_request(conn) + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, promised_ref, _}]} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers()), + promised_stream_id: 2, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + assert {:error, %HTTP2{} = conn, error} = + HTTP2.stream_request_body(conn, promised_ref, {:eof, [{"x-trailer", "value"}]}) + + assert_http2_error error, :request_is_not_streaming + refute_receive {:ssl, _socket, _data}, 100 + + assert {conn.open_client_stream_count, conn.open_server_stream_count, + conn.reserved_server_stream_count} == {1, 0, 1} + + assert conn.streams[2].state == :reserved_remote + end + test "unallowed trailer headers cause an error", %{conn: conn} do {conn, ref} = open_request(conn, :stream) From cf54638f3b6336da4ff66f95e24dfe9c4eb39e0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 27 Sep 2026 16:05:52 +0200 Subject: [PATCH 7/7] Reject HTTP/2 frames on server streams that were never promised Frames on an even stream ID that was never promised were ignored. A server only opens streams through PUSH_PROMISE (RFC 9113 8.4 and 5.1.1), so any frame other than PRIORITY on such an idle stream is now a connection error, the same treatment client streams above the next stream ID already got. --- lib/mint/http2.ex | 17 +++++++++++++---- test/mint/http2/conn_test.exs | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 758e614e..f48cd556 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -1875,13 +1875,22 @@ defmodule Mint.HTTP2 do :ok end - # 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. + # RFC 9113 5.1: PRIORITY frames are allowed on idle streams. Client streams are + # opened in order, so odd stream IDs from next_stream_id on are idle and the server + # can't send other frames on them. Server streams are only opened through + # PUSH_PROMISE (RFC 9113 8.4 and 5.1.1), so even stream IDs above the last promised + # one are idle too. 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 + idle? = + cond do + stream_id == 0 -> false + Integer.is_odd(stream_id) -> stream_id >= conn.next_stream_id + true -> stream_id > conn.last_promised_stream_id + end + + if idle? do debug_data = "frame with stream ID #{inspect(stream_id)} has not been opened yet" send_connection_error!(conn, :protocol_error, debug_data) else diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index e95caecf..6a7d7f47 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -2782,6 +2782,29 @@ defmodule Mint.HTTP2Test do refute HTTP2.open?(conn) end + for {frame_name, frame} <- [ + headers: quote(do: {:headers, 2, [{":status", "200"}], [:end_headers]}), + data: quote(do: data(stream_id: 2, data: "some data")), + rst_stream: quote(do: rst_stream(stream_id: 2, error_code: :cancel)), + window_update: quote(do: window_update(stream_id: 2, window_size_increment: 1)) + ] do + test "a #{frame_name} frame on a server stream that was never promised is a connection error", + %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: _stream_id)] + + assert {:error, %HTTP2{} = conn, error, []} = stream_frames(conn, [unquote(frame)]) + + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "frame with stream ID 2 has not been opened yet" + + assert_recv_frames [goaway(error_code: :protocol_error)] + + refute HTTP2.open?(conn) + end + end + test "PRIORITY frames on idle streams are ignored", %{conn: conn} do {conn, _ref} = open_request(conn)