From 81963866cf257fb72795ce007863408c93f574d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 14:12:32 +0200 Subject: [PATCH 1/5] Bound buffered header blocks by the encoded size of the list limit While waiting for CONTINUATION frames, the client compared the size of the buffered compressed header block with its advertised SETTINGS_MAX_HEADER_LIST_SIZE, assuming a compressed block is never larger than the header list it decodes to. Huffman coding can make a string longer than its octets (RFC 7541 5.2), so a valid block whose decoded list is under the limit could exceed it on the wire. With a limit of 1,000 bytes, a 475-byte header list that encodes to 1,307 bytes was accepted in a single HEADERS frame and closed the connection with PROTOCOL_ERROR when split over HEADERS and CONTINUATION. The limit is enforced on the decoded header list, and the fragments buffered before END_HEADERS are bounded by an upper bound on the encoded size of a header list within the limit, assuming minimal integer representations (RFC 7541 5.1) and at most two dynamic table size updates (RFC 7541 4.2). HPACK decoders accept redundant zero continuation bytes in integers and any number of size updates, so no finite bound covers every block that decodes to such a list. The fragment that carries END_HEADERS isn't counted, and the complete block is then judged by its decoded size. A Huffman-coded octet takes at most 30 bits (RFC 7541 Appendix B). A minimal integer below 2^32 takes at most 6 bytes, so a field has at most 13 bytes of prefixes and integers (a representation byte, the name length and the value length) and less than 2 bytes of Huffman padding, 15 bytes in total. That's less than the 120 bytes its 32-byte overhead (RFC 9113 6.5.2) contributes at the 30/8 ratio. The two size updates take at most 6 bytes each. The bound is max_header_list_size * 30 / 8 + 12 bytes, and a peer that buffers more than that gets a connection error. --- lib/mint/http2.ex | 38 ++++++++--- test/mint/http2/conn_test.exs | 120 ++++++++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 15 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 227d6822..1979eefc 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -2954,22 +2954,40 @@ defmodule Mint.HTTP2 do # 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. Empty fragments are not retained in the - # accumulator. The compressed accumulator is never larger than the - # uncompressed header list it decodes to, so the size limit never rejects a - # header block that fits within the advertised limit. + # indefinitely, so the buffered fragments are bounded by the largest encoding + # of a header list within the locally advertised SETTINGS_MAX_HEADER_LIST_SIZE, + # which is enforced on the decoded list. The fragment that carries END_HEADERS + # isn't buffered or counted. Empty fragments are not retained in the + # accumulator. + # + # The bound assumes minimal integer representations (RFC 7541 5.1) and at most + # two dynamic table size updates (RFC 7541 4.2). HPACK decoders also accept + # redundant zero continuation bytes in integers and any number of size updates, + # which no finite bound covers. A Huffman-coded octet takes at most 30 bits + # (RFC 7541 Appendix B), and a field's representation byte, integers (at most 6 + # bytes each below 2^32) and Huffman padding take less than 15 bytes, so a + # field encodes to less than the (name + value + 32) * 30 / 8 bytes that its + # decoded size (RFC 9113 6.5.2) allows. A size update takes at most 6 bytes, + # since SETTINGS values are 32-bit (RFC 9113 6.5.1). + @max_dynamic_table_size_updates_size 2 * 6 + 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 -> + max_block_size = div(max_size * 30, 8) + @max_dynamic_table_size_updates_size - _max_size -> - conn + if size > max_block_size do + debug_data = + "header block fragments exceed #{max_block_size} bytes, the bound for " <> + "SETTINGS_MAX_HEADER_LIST_SIZE of #{max_size} bytes" + + send_connection_error!(conn, :protocol_error, debug_data) + else + conn + end end end diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 2a80d667..40d4f7e7 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -1052,10 +1052,11 @@ defmodule Mint.HTTP2Test do 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) + # accumulate past the upper bound on the encoded size of a header list + # within the advertised SETTINGS_MAX_HEADER_LIST_SIZE (1_000 * 30 / 8 + 12 + # bytes). 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>>, 1_300) assert {:error, %HTTP2{} = conn, error, []} = stream_frames(conn, [ @@ -1085,6 +1086,74 @@ defmodule Mint.HTTP2Test do refute HTTP2.open?(conn) end + @tag connect_options: [client_settings: [max_header_list_size: 1_000]] + test "header block fragments buffered up to the encoded size bound are accepted", + %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + # 1_000 * 30 / 8 + 12 = 3_762 bytes. + assert {:ok, %HTTP2{} = conn, []} = + stream_frames(conn, [ + headers( + stream_id: stream_id, + hbf: :binary.copy(<<0>>, 1_001), + flags: set_flags(:headers, []) + ), + continuation( + stream_id: stream_id, + hbf: :binary.copy(<<0>>, 2_761), + flags: set_flags(:continuation, []) + ) + ]) + + assert {^stream_id, _hbf, _callback, 3_762} = conn.headers_being_processed + assert HTTP2.open?(conn) + + assert {:error, %HTTP2{} = conn, error, []} = + stream_frames(conn, [ + continuation( + stream_id: stream_id, + hbf: <<0>>, + flags: set_flags(:continuation, []) + ) + ]) + + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "fragments exceed 3762 bytes" + refute HTTP2.open?(conn) + end + + @tag connect_options: [client_settings: [max_header_list_size: 1_000]] + test "the fragment that ends a header block is not counted against the buffered size", + %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + # 4_000 dynamic table size updates to 0 followed by the indexed :status 200 + # field are 4_001 bytes that decode to a 42-byte header list. + hbf = :binary.copy(<<0x20>>, 4_000) <> <<0x88>> + {hbf1, hbf2} = :erlang.split_binary(hbf, 1_000) + + assert {:ok, %HTTP2{} = conn, [{:status, ^ref, 200}, {:headers, ^ref, []}, {:done, ^ref}]} = + stream_frames(conn, [ + headers( + stream_id: stream_id, + hbf: hbf1, + flags: set_flags(:headers, [:end_stream]) + ), + continuation( + stream_id: stream_id, + hbf: hbf2, + flags: set_flags(:continuation, [:end_headers]) + ) + ]) + + assert HTTP2.open?(conn) + end + # Regression for GitHub Security Advisory GHSA-8pf6-g464-h6h9. test "empty CONTINUATION frames do not grow the header block accumulator", %{conn: conn} do {conn, _ref} = open_request(conn) @@ -1251,6 +1320,40 @@ defmodule Mint.HTTP2Test do assert HTTP2.open?(conn) end + # RFC 7541 5.2: Huffman coding can make a string longer than its octets, so an + # encoded block can be larger than the header list it decodes to. + @tag connect_options: [client_settings: [max_header_list_size: 1_000]] + test "a Huffman-coded header list under max_header_list_size is accepted when split " <> + "over CONTINUATION frames", + %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + # A 475-byte header list that encodes to 1_307 bytes. + value = :binary.copy(<<255>>, 400) + hbf = huffman_encode_headers([{":status", "200"}, {"x", value}]) + assert byte_size(hbf) == 1_307 + {hbf1, hbf2} = :erlang.split_binary(hbf, 1_001) + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + headers( + stream_id: stream_id, + hbf: hbf1, + flags: set_flags(:headers, [:end_stream]) + ), + continuation( + stream_id: stream_id, + hbf: hbf2, + flags: set_flags(:continuation, [:end_headers]) + ) + ]) + + assert [{:status, ^ref, 200}, {:headers, ^ref, [{"x", ^value}]}, {:done, ^ref}] = responses + assert HTTP2.open?(conn) + end + @tag connect_options: [client_settings: [max_header_list_size: 1_000]] test "a decoded trailer list past max_header_list_size is a stream error", %{conn: conn} do {conn, ref} = open_request(conn) @@ -1303,7 +1406,7 @@ defmodule Mint.HTTP2Test do assert_recv_frames [headers(stream_id: stream_id)] - oversized_hbf = :binary.copy(<<0>>, 2_000) + oversized_hbf = :binary.copy(<<0>>, 3_763) assert {:error, %HTTP2{} = conn, error, []} = stream_frames(conn, [ @@ -4766,6 +4869,13 @@ defmodule Mint.HTTP2Test do hbf end + # Encodes without touching the dynamic table, so the server's HPAX context stays in sync. + defp huffman_encode_headers(headers) do + table = HPAX.new(4096, huffman_encoding: :always) + {hbf, _table} = HPAX.encode(:no_store, headers, table) + IO.iodata_to_binary(hbf) + end + defp server_decode_headers(hbf) do server = Process.get(@server_pdict_key) {server, headers} = TestServer.decode_headers(server, hbf) From 338cf40bad3b586a20a0431f0ef8147ca7e6efa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 14:13:00 +0200 Subject: [PATCH 2/5] Document how :max_header_list_size limits are enforced The docs said a header block larger than :max_header_list_size closes the connection. A decoded response header list over the limit resets the request with a PROTOCOL_ERROR stream error and returns a {:max_header_list_size_exceeded, size, max_size} error, and promised request headers over the limit reset the promised stream with REFUSED_STREAM. The connection stays open in both cases. Only the header block fragments buffered while waiting for END_HEADERS are a connection error, when they're over an upper bound on the encoded size of a header list within the limit. The fragment that carries END_HEADERS isn't counted, and the complete block is then judged by its decoded size. The docs also say that the server setting is :infinity until the server sends it, and that the client setting must be an integer, since put_settings/2 and the :client_settings option raise on :infinity. --- lib/mint/http2.ex | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 1979eefc..45ae5315 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -309,15 +309,18 @@ 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`. 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. A - decoded header list larger than this value fails the request with a - `{:max_header_list_size_exceeded, size, max_size}` error, and a server push whose - promised request headers are larger than this value is refused. Defaults to - `256 KB` for the client. + * `:max_header_list_size` - corresponds to `SETTINGS_MAX_HEADER_LIST_SIZE`. The + server setting is `:infinity` until the server sends it. The client setting must be + an integer and defaults to `256 KB`. It limits the decoded size (names, values and + 32 bytes per field) of header lists received from the server. Response headers, + informational response headers and trailers over the limit reset the request with + `PROTOCOL_ERROR` and fail it with a `{:max_header_list_size_exceeded, size, max_size}` + error. Promised request headers over the limit reset the promised stream with + `REFUSED_STREAM`. The connection stays open in both cases. Header block fragments + buffered while waiting for `END_HEADERS` close the connection with `PROTOCOL_ERROR` + if they exceed `max_header_list_size * 30 / 8 + 12` bytes, an upper bound on the + encoded size of a header list within the limit (assuming minimal integer + representations and at most two dynamic table size updates). * `:enable_connect_protocol` - corresponds to `SETTINGS_ENABLE_CONNECT_PROTOCOL`. Sets whether the client may invoke the extended connect protocol which is used to From 6f91efe05cc1abf0cfad78ed2efc5f28b99a7a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 14:14:37 +0200 Subject: [PATCH 3/5] Validate the target of promised requests A PUSH_PROMISE was accepted as long as its :scheme and :authority were non-empty and its :path started with "/", so promises with :authority "user@localhost" or "localhost:abc", :path "/a b" or "/a#fragment", or :scheme "1https" were returned as :push_promise responses. RFC 9113 8.4.1 has the client reset a promised stream whose request is malformed with PROTOCOL_ERROR. The :scheme is checked against the RFC 3986 3.1 scheme grammar. The :authority is parsed as RFC 3986 3.2 [ userinfo "@" ] host [ ":" port ], where the host is an IP-literal or a reg-name and the port is zero or more digits. An IP-literal is an IPvFuture or an IPv6 address, optionally followed by "%25" and an RFC 6874 zone ID made of unreserved and percent-encoded characters. The IPv6 address must be made of HEXDIG, ":" and "." characters and is then checked with :inet.parse_ipv6strict_address/1, which on its own accepts a zone ID after a bare "%" and signs in embedded IPv4 octets. RFC 9113 8.3.1 forbids userinfo for the http and https schemes, and RFC 9110 4.2.1 and 4.2.2 forbid an empty host for them, so ":443" or "@localhost" is rejected for those schemes. Both checks compare the scheme case-insensitively, and other schemes keep accepting userinfo and an empty host. An :authority that is entirely empty is rejected for every scheme, as before. The :path is checked as an RFC 9110 absolute-path with an optional query (RFC 9113 8.3.1), made of pchar, "/" and "?" characters with valid percent-encoding. --- lib/mint/http2.ex | 116 +++++++++++++++++++++++++++++++- test/mint/http2/conn_test.exs | 123 ++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 1 deletion(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 45ae5315..31f3c5e4 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -126,6 +126,7 @@ defmodule Mint.HTTP2 do """ import Mint.HTTP2.Frame, except: [encode: 1, decode_next: 1, inspect: 1] + import Mint.HTTP1.Parse, only: [is_alpha: 1, is_digit: 1, is_hex_digit: 1] alias Mint.{HTTPError, ParsingTools, TransportError} alias Mint.Types @@ -2743,10 +2744,16 @@ defmodule Mint.HTTP2 do pseudo[":scheme"] in [nil, ""] -> "missing or empty :scheme pseudo-header in promised request" + not valid_scheme?(pseudo[":scheme"]) -> + "invalid :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"] || "", "/") -> + not valid_authority?(pseudo[":scheme"], pseudo[":authority"]) -> + "invalid :authority pseudo-header in promised request" + + not valid_path?(pseudo[":path"]) -> "missing or invalid :path pseudo-header in promised request" pseudo[":method"] not in ["GET", "HEAD"] -> @@ -2762,6 +2769,113 @@ defmodule Mint.HTTP2 do end end + # RFC 3986 3.1: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + defp valid_scheme?(<>) when is_alpha(char), do: uri_chars?(rest, :scheme) + defp valid_scheme?(_scheme), do: false + + # RFC 3986 3.2: authority = [ userinfo "@" ] host [ ":" port ]. RFC 9113 8.3.1 + # forbids userinfo for the "http" and "https" schemes, and RFC 9110 4.2.1 and + # 4.2.2 forbid an empty host for them. + defp valid_authority?(scheme, authority) do + http? = String.downcase(scheme, :ascii) in ["http", "https"] + + case String.split(authority, "@", parts: 2) do + [host_and_port] -> + valid_host_and_port?(host_and_port, http?) + + [userinfo, host_and_port] -> + not http? and uri_chars?(userinfo, :userinfo) and + valid_host_and_port?(host_and_port, http?) + end + end + + defp valid_host_and_port?("[" <> rest, _http?) do + case String.split(rest, "]", parts: 2) do + [ip_literal, port] -> valid_ip_literal?(ip_literal) and valid_port?(port) + [_rest] -> false + end + end + + defp valid_host_and_port?(host_and_port, http?) do + {reg_name, port} = + case String.split(host_and_port, ":", parts: 2) do + [reg_name] -> {reg_name, ""} + [reg_name, port] -> {reg_name, ":" <> port} + end + + # An IPv4address is also a valid reg-name. + (reg_name != "" or not http?) and uri_chars?(reg_name, :reg_name) and valid_port?(port) + end + + # RFC 3986 3.2.3: port = *DIGIT, after a ":". + defp valid_port?(""), do: true + defp valid_port?(":" <> port), do: port == "" or ParsingTools.only_digits?(port) + defp valid_port?(_port), do: false + + # RFC 3986 3.2.2: IP-literal = "[" ( IPv6address / IPvFuture ) "]", where + # IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ). RFC 6874 2 + # allows an IPv6address to be followed by "%25" and a zone ID. + defp valid_ip_literal?(<>) when v in ~c"vV" do + case String.split(rest, ".", parts: 2) do + [version, address] -> + version != "" and uri_chars?(version, :hex) and address != "" and + uri_chars?(address, :ipvfuture) + + [_rest] -> + false + end + end + + defp valid_ip_literal?(ip_literal) do + case String.split(ip_literal, "%25", parts: 2) do + [address] -> + ipv6_address?(address) + + [address, zone_id] -> + ipv6_address?(address) and zone_id != "" and uri_chars?(zone_id, :zone_id) + end + end + + # :inet.parse_ipv6strict_address/1 accepts a zone ID after a bare "%" and signs + # in embedded IPv4 octets, which the RFC 3986 3.2.2 IPv6address grammar doesn't + # allow, so the address is first restricted to HEXDIG, ":" and ".". + defp ipv6_address?(address) do + uri_chars?(address, :ipv6) and + match?({:ok, _}, :inet.parse_ipv6strict_address(:binary.bin_to_list(address))) + end + + # RFC 9113 8.3.1: :path is the RFC 9110 absolute-path, optionally followed by "?" + # and the RFC 3986 3.4 query, so it's made of pchar, "/" and "?" characters. + defp valid_path?("/" <> rest), do: uri_chars?(rest, :path) + defp valid_path?(_path), do: false + + # Checks that a binary is made of characters of the given class, and of RFC 3986 + # 2.1 pct-encoded characters for the classes that allow them. + defp uri_chars?(<>, class) + when class in [:userinfo, :reg_name, :zone_id, :path] and is_hex_digit(hex1) and + is_hex_digit(hex2), + do: uri_chars?(rest, class) + + defp uri_chars?(<>, class), + do: uri_char?(char, class) and uri_chars?(rest, class) + + defp uri_chars?(<<>>, _class), do: true + + # RFC 3986 2.3 unreserved and 2.2 sub-delims characters. + defguardp is_unreserved(char) when is_alpha(char) or is_digit(char) or char in ~c"-._~" + defguardp is_sub_delim(char) when char in ~c"!$&'()*+,;=" + + defp uri_char?(char, :scheme), do: is_alpha(char) or is_digit(char) or char in ~c"+-." + defp uri_char?(char, :hex), do: is_hex_digit(char) + defp uri_char?(char, :ipv6), do: is_hex_digit(char) or char in ~c":." + defp uri_char?(char, :zone_id), do: is_unreserved(char) + defp uri_char?(char, :reg_name), do: is_unreserved(char) or is_sub_delim(char) + + defp uri_char?(char, class) when class in [:userinfo, :ipvfuture], + do: is_unreserved(char) or is_sub_delim(char) or char == ?: + + defp uri_char?(char, :path), do: is_unreserved(char) or is_sub_delim(char) or char in ~c":@/?" + defp validate_promised_fields([], pseudo, _regular?), do: {:ok, pseudo} defp validate_promised_fields([{":" <> _ = name, value} | rest], pseudo, regular?) do diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 40d4f7e7..f4f4647b 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -2561,6 +2561,30 @@ defmodule Mint.HTTP2Test do {":authority", "localhost"}, {":path", "/"}, {"content-length", "zero"} + ], + space_in_path: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/a b"} + ], + fragment_in_path: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/a#fragment"} + ], + invalid_percent_encoding_in_path: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/a%2"} + ], + scheme_starting_with_digit: [ + {":method", "GET"}, + {":scheme", "1https"}, + {":authority", "localhost"}, + {":path", "/"} ] ] do test "a PUSH_PROMISE with #{variant} in the promised request resets the promised stream", @@ -2613,6 +2637,105 @@ defmodule Mint.HTTP2Test do end end + # RFC 6874 2: a zone ID is made of unreserved and percent-encoded characters. + invalid_zone_id_authorities = + for char <- ~c"!$&'()*+,;=", do: {"https", "[fe80::1%25en#{<>}0]"} + + for {scheme, authority} <- + [ + {"https", "user@localhost"}, + {"http", "user@localhost"}, + {"HTTPS", "user@localhost"}, + {"https", "localhost:abc"}, + {"https", "[::1"}, + {"https", "[garbage]"}, + {"https", "localhost:80:90"}, + {"https", "a[b]"}, + {"https", "[v.z]"}, + {"https", "]]]"}, + {"https", ":443"}, + {"https", "%zz"}, + {"https", "[fe80::1%en0]"}, + {"https", "[::ffff:192.+1.2.3]"}, + {"https", "[::ffff:01.2.3.4]"}, + {"https", "[fe80::1%25]"}, + {"https", "[v1.a%20]"}, + {"https", "[vg.x]"}, + {"ftp", "us[er@localhost"} + ] ++ invalid_zone_id_authorities do + test "a PUSH_PROMISE with :scheme #{scheme} and :authority #{inspect(authority)} " <> + "resets the promised stream", + %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + promised_headers = [ + {":method", "GET"}, + {":scheme", unquote(scheme)}, + {":authority", unquote(authority)}, + {":path", "/"} + ] + + assert {:ok, %HTTP2{} = conn, []} = + 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_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 {scheme, authority, path} <- [ + {"https", "[::1]:8443", "/"}, + {"https", "[fe80::1%25en0]:443", "/"}, + {"https", "[fe80::1%25en%210]", "/"}, + {"https", "[::ffff:192.1.2.3]", "/"}, + {"https", "[v1.fe80::a+en1]", "/"}, + {"https", "localhost:", "/"}, + {"https", "%41bc", "/"}, + {"https", "127.0.0.1:80", "/"}, + {"HTTPS", "localhost", "/"}, + {"ftp", "user@localhost", "/"}, + {"ftp", ":21", "/"}, + {"coap+tcp-1.0", "localhost", "//a/b;c=d/~e:f@g!$&'()*+,?h=i%20j/?k"} + ] do + test "a PUSH_PROMISE with :scheme #{scheme}, :authority #{inspect(authority)} and " <> + ":path #{inspect(path)} is accepted", + %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + promised_headers = [ + {":method", "GET"}, + {":scheme", unquote(scheme)}, + {":authority", unquote(authority)}, + {":path", unquote(path)} + ] + + 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}, From c41c68dddd831aac95648b6ed2623e469e8ac9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 14:15:26 +0200 Subject: [PATCH 4/5] Remove unreachable :infinity client max_header_list_size branches The client :max_header_list_size setting is always an integer: it defaults to 256 KB, and the validation in connect/4 and put_settings/2 raises an ArgumentError for any other value, including :infinity. The branches that handled an :infinity client value when measuring decoded header lists and buffered header blocks could never run, so they're removed. The server's SETTINGS_MAX_HEADER_LIST_SIZE is still :infinity until the server sends it, and its handling is unchanged. Tests pin the validation that rejects :infinity for the client setting in connect/4 and put_settings/2. --- lib/mint/http2.ex | 43 ++++++++++++++--------------------- test/mint/http2/conn_test.exs | 16 +++++++++++++ 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 31f3c5e4..b3aaea74 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -2210,19 +2210,15 @@ defmodule Mint.HTTP2 do # 32 bytes per field. The compressed block is bounded while it is accumulated, # but indexed fields decode to far more bytes than they take on the wire. defp header_list_size_error(conn, headers) do - case conn.client_settings.max_header_list_size do - :infinity -> - nil - - max_size -> - # TODO: replace with Enum.sum_by when we depend on 1.18+ - size = - Enum.reduce(headers, 0, fn {name, value}, acc -> - acc + byte_size(name) + byte_size(value) + 32 - end) + max_size = conn.client_settings.max_header_list_size - if size > max_size, do: {:max_header_list_size_exceeded, size, max_size} - end + # TODO: replace with Enum.sum_by when we depend on 1.18+ + size = + Enum.reduce(headers, 0, fn {name, value}, acc -> + acc + byte_size(name) + byte_size(value) + 32 + end) + + if size > max_size, do: {:max_header_list_size_exceeded, size, max_size} end # RFC 9113 5.1: HEADERS frames move a stream reserved by a PUSH_PROMISE to the @@ -3089,22 +3085,17 @@ defmodule Mint.HTTP2 do @max_dynamic_table_size_updates_size 2 * 6 defp assert_header_block_within_max_size(conn, size) do - case conn.client_settings.max_header_list_size do - :infinity -> - conn - - max_size -> - max_block_size = div(max_size * 30, 8) + @max_dynamic_table_size_updates_size + max_size = conn.client_settings.max_header_list_size + max_block_size = div(max_size * 30, 8) + @max_dynamic_table_size_updates_size - if size > max_block_size do - debug_data = - "header block fragments exceed #{max_block_size} bytes, the bound for " <> - "SETTINGS_MAX_HEADER_LIST_SIZE of #{max_size} bytes" + if size > max_block_size do + debug_data = + "header block fragments exceed #{max_block_size} bytes, the bound for " <> + "SETTINGS_MAX_HEADER_LIST_SIZE of #{max_size} bytes" - send_connection_error!(conn, :protocol_error, debug_data) - else - conn - end + send_connection_error!(conn, :protocol_error, debug_data) + else + conn end end diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index f4f4647b..60ae7542 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -313,6 +313,18 @@ defmodule Mint.HTTP2Test do end end + test "raises an error if the client :max_header_list_size setting is :infinity", + %{server_port: port} do + message = ":max_header_list_size must be an integer, got: :infinity" + + assert_raise ArgumentError, message, fn -> + HTTP2.connect(:https, "localhost", port, + client_settings: [max_header_list_size: :infinity], + transport_opts: [verify: :verify_none] + ) + end + end + test "closes the transport socket if anything goes wrong during the setup", %{server_port: port} do {:ok, socket} = :ssl.connect(~c"localhost", port, verify: :verify_none) @@ -4070,6 +4082,10 @@ defmodule Mint.HTTP2Test do HTTP2.put_settings(conn, header_table_size: :oops) end + assert_raise ArgumentError, ~r/:max_header_list_size must be an integer/, fn -> + HTTP2.put_settings(conn, max_header_list_size: :infinity) + end + assert_raise ArgumentError, "unknown setting parameter :oops", fn -> HTTP2.put_settings(conn, oops: 1) end From f707415a1199ec76812b001925841af938bb7478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 28 Sep 2026 15:11:40 +0200 Subject: [PATCH 5/5] Treat a 204 response to CONNECT as an established tunnel The check for 204 and 304 responses, which must not have content, came before the check for 2xx responses to CONNECT. A CONNECT request answered with 204 was treated as a response without content, so the first DATA frame on the tunnel failed the request with a protocol error. RFC 9110 9.3.6 and RFC 9113 8.5 make any 2xx response to CONNECT establish a tunnel, so the CONNECT case is checked first and DATA frames after a 204 are delivered as tunnel data. --- lib/mint/http2.ex | 5 +++-- test/mint/http2/conn_test.exs | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index b3aaea74..670a16a1 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -2340,12 +2340,13 @@ defmodule Mint.HTTP2 do # RFC 9113 8.1.1: a response with content is malformed if the sum of the DATA # frame payload lengths doesn't equal the content-length header value. Responses # to HEAD and 204 and 304 responses must not have content, whatever their - # content-length header says, and 2xx responses to CONNECT carry tunnel data. + # content-length header says. Any 2xx response to CONNECT, including 204, + # establishes a tunnel (RFC 9110 9.3.6, RFC 9113 8.5) and carries tunnel data. defp response_content_length(%{method: method}, status, headers) do cond do method == "HEAD" -> {:ok, 0} - status in [204, 304] -> {:ok, 0} method == "CONNECT" and status in 200..299 -> {:ok, nil} + status in [204, 304] -> {:ok, 0} true -> content_length(headers) end end diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 60ae7542..60495c24 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -4306,6 +4306,33 @@ defmodule Mint.HTTP2Test do assert HTTP2.open?(conn) end + test "a 204 response establishes the tunnel", %{conn: conn} do + assert {:ok, conn, ref} = HTTP2.request(conn, "CONNECT", "example.com:443", [], :stream) + + assert_recv_frames [headers(stream_id: stream_id)] + + hbf = server_encode_headers([{":status", "204"}]) + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + headers( + stream_id: stream_id, + hbf: hbf, + flags: set_flags(:headers, [:end_headers]) + ), + data(stream_id: stream_id, data: "hello", flags: set_flags(:data, [:end_stream])) + ]) + + assert responses == [ + {:status, ref, 204}, + {:headers, ref, []}, + {:data, ref, "hello"}, + {:done, ref} + ] + + assert HTTP2.open?(conn) + end + test "an END_STREAM from the server closes the whole tunnel", %{conn: conn} do assert {:ok, conn, ref} = HTTP2.request(conn, "CONNECT", "example.com:443", [], :stream)