From 200b72c40e84996192ffc65388b54ee0e311c7e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 21 Sep 2026 13:03:36 +0200 Subject: [PATCH 1/2] Apply the acknowledged HTTP/2 header table size to the decoding table put_settings/2 accepted :header_table_size but the handler for the server's SETTINGS ACK didn't know the setting and raised a RuntimeError out of stream/2. The setting is now recorded and the HPACK decoding table's permitted maximum follows it. That maximum is a limit rather than the size of the table. The table's own maximum belongs to the server's encoder, which declares it with a dynamic table size update (RFC 7541 4.2), so HPAX.resize/2, which moves both, would keep entries the server had evicted and decode an indexed reference to one of them as a stale header. HPAX.protocol_resize/2 moves only the limit and follows it down when it drops below the size the encoder declared. After a reduction the server has to declare its new size at the start of the next field block, which RFC 9113 4.3.1 requires the client to enforce, so a block that doesn't is a connection error with COMPRESSION_ERROR. --- lib/mint/http2.ex | 9 +- mix.exs | 2 +- mix.lock | 2 +- test/mint/http2/conn_test.exs | 160 ++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 3 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 516bc618..ed740ae4 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -163,6 +163,7 @@ defmodule Mint.HTTP2 do @default_max_header_list_size 256 * 1024 @valid_client_settings [ + :header_table_size, :max_concurrent_streams, :initial_window_size, :max_frame_size, @@ -240,6 +241,7 @@ defmodule Mint.HTTP2 do # Settings that the client communicates to the server. client_settings: %{ + header_table_size: 4096, max_concurrent_streams: 100, initial_window_size: @default_stream_window_size, max_header_list_size: @default_max_header_list_size, @@ -2296,13 +2298,18 @@ defmodule Mint.HTTP2 do end end - valid_client_settings_without_iws = @valid_client_settings -- [:initial_window_size] + valid_client_settings_without_iws = + @valid_client_settings -- [:initial_window_size, :header_table_size] defp apply_client_settings(conn, client_settings) do Enum.reduce(client_settings, conn, fn {:initial_window_size, initial_window_size}, conn -> update_client_initial_window_size(conn, initial_window_size) + {:header_table_size, header_table_size}, conn -> + conn = update_in(conn.decode_table, &HPAX.protocol_resize(&1, header_table_size)) + put_in(conn.client_settings.header_table_size, header_table_size) + {setting, value}, conn when setting in unquote(valid_client_settings_without_iws) -> update_in(conn.client_settings, &%{&1 | setting => value}) diff --git a/mix.exs b/mix.exs index 99aea55f..658ee19b 100644 --- a/mix.exs +++ b/mix.exs @@ -77,7 +77,7 @@ defmodule Mint.MixProject do defp deps do [ {:castore, "~> 0.1.0 or ~> 1.0", optional: true}, - {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0"}, + {:hpax, github: "elixir-mint/hpax"}, # Dev/test dependencies {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 37b23788..aa7cf0ea 100644 --- a/mix.lock +++ b/mix.lock @@ -5,7 +5,7 @@ "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, - "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "hpax": {:git, "https://github.com/elixir-mint/hpax.git", "f7d69fc7b33310c4da9165d44ba56cee97df4989", []}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 056580d2..320ff511 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -2430,6 +2430,146 @@ defmodule Mint.HTTP2Test do end describe "settings" do + test "the header table size setting is applied when the server acknowledges it", + %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + {:ok, conn} = HTTP2.put_settings(conn, header_table_size: 8192) + assert_recv_frames [settings(params: [header_table_size: 8192])] + + assert {:ok, %HTTP2{} = conn, []} = + stream_frames(conn, [settings(flags: set_flags(:settings, [:ack]), params: [])]) + + assert HTTP2.get_client_setting(conn, :header_table_size) == 8192 + + # The server can now signal a dynamic table size of 8192 at the start of a block. + server = Process.get(@server_pdict_key) + Process.put(@server_pdict_key, update_in(server.encode_table, &HPAX.resize(&1, 8192))) + + assert {:ok, %HTTP2{}, [{:status, ^ref, 200}, {:headers, ^ref, []}, {:done, ^ref}]} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}], [:end_headers, :end_stream]} + ]) + end + + test "a raised header table size leaves the table size to the server", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + # The server picks a 64 byte table and stores ":status: 404" in it, which takes 42 bytes. + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + headers( + stream_id: stream_id, + hbf: <<0x3F, 0x21, 0x48, 3, "404">>, + flags: set_flags(:headers, [:end_headers, :end_stream]) + ) + ]) + + assert [{:status, ^ref, 404}, {:headers, ^ref, []}, {:done, ^ref}] = responses + + {:ok, conn} = HTTP2.put_settings(conn, header_table_size: 8192) + assert_recv_frames [settings(params: [header_table_size: 8192])] + + assert {:ok, %HTTP2{} = conn, []} = + stream_frames(conn, [settings(flags: set_flags(:settings, [:ack]), params: [])]) + + # The server didn't ask for more room, so storing ":status: 500" evicts ":status: 404". + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + headers( + stream_id: stream_id, + hbf: <<0x48, 3, "500">>, + flags: set_flags(:headers, [:end_headers, :end_stream]) + ) + ]) + + assert [{:status, ^ref, 500}, {:headers, ^ref, []}, {:done, ^ref}] = responses + + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:error, %HTTP2{} = conn, error, []} = + stream_frames(conn, [ + headers( + stream_id: stream_id, + hbf: <<0xBF>>, + flags: set_flags(:headers, [:end_headers, :end_stream]) + ) + ]) + + assert_http2_error error, {:compression_error, debug_data} + assert debug_data =~ "unable to decode headers: {:index_not_found, 63}" + + assert_recv_frames [goaway(error_code: :compression_error)] + + refute HTTP2.open?(conn) + end + + test "a lowered header table size has to be signalled by the server", %{conn: conn} do + conn = fill_server_header_table(conn) + + {:ok, conn} = HTTP2.put_settings(conn, header_table_size: 0) + assert_recv_frames [settings(params: [header_table_size: 0])] + + assert {:ok, %HTTP2{} = conn, []} = + stream_frames(conn, [settings(flags: set_flags(:settings, [:ack]), params: [])]) + + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:error, %HTTP2{} = conn, error, []} = + stream_frames(conn, [ + headers( + stream_id: stream_id, + hbf: <<0x88>>, + flags: set_flags(:headers, [:end_headers, :end_stream]) + ) + ]) + + assert_http2_error error, {:compression_error, debug_data} + assert debug_data =~ "unable to decode headers: :missing_size_update" + + assert_recv_frames [goaway(error_code: :compression_error)] + + refute HTTP2.open?(conn) + end + + test "a response that signals the lowered header table size is accepted", %{conn: conn} do + conn = fill_server_header_table(conn) + + {:ok, conn} = HTTP2.put_settings(conn, header_table_size: 0) + assert_recv_frames [settings(params: [header_table_size: 0])] + + assert {:ok, %HTTP2{} = conn, []} = + stream_frames(conn, [settings(flags: set_flags(:settings, [:ack]), params: [])]) + + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + headers( + stream_id: stream_id, + hbf: <<0x20, 0x88>>, + flags: set_flags(:headers, [:end_headers, :end_stream]) + ) + ]) + + assert [{:status, ^ref, 200}, {:headers, ^ref, []}, {:done, ^ref}] = responses + assert HTTP2.open?(conn) + end + @tag connect_options: [ receive_window_update_threshold: 8, client_settings: [initial_window_size: 16] @@ -3210,6 +3350,26 @@ defmodule Mint.HTTP2Test do end end + defp fill_server_header_table(conn) do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + # ":status: 404" stored with incremental indexing, 42 bytes in the server's table. + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + headers( + stream_id: stream_id, + hbf: <<0x48, 3, "404">>, + flags: set_flags(:headers, [:end_headers, :end_stream]) + ) + ]) + + assert [{:status, ^ref, 404}, {:headers, ^ref, []}, {:done, ^ref}] = responses + + conn + end + defp start_server_async(_context) do {:ok, port, server_socket_task} = TestServer.listen_and_accept() %{server_port: port, server_socket_task: server_socket_task} From 1e2a5f7e1eee14028073f033399f501a2911c187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 24 Sep 2026 11:50:16 +0200 Subject: [PATCH 2/2] Require hpax 1.1 HPAX.protocol_resize/2 was added in hpax 1.1.0, so older versions can't be used anymore. --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 658ee19b..15957739 100644 --- a/mix.exs +++ b/mix.exs @@ -77,7 +77,7 @@ defmodule Mint.MixProject do defp deps do [ {:castore, "~> 0.1.0 or ~> 1.0", optional: true}, - {:hpax, github: "elixir-mint/hpax"}, + {:hpax, "~> 1.1"}, # Dev/test dependencies {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index aa7cf0ea..bfbb6b9e 100644 --- a/mix.lock +++ b/mix.lock @@ -5,7 +5,7 @@ "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, - "hpax": {:git, "https://github.com/elixir-mint/hpax.git", "f7d69fc7b33310c4da9165d44ba56cee97df4989", []}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},