diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 227d6822..670a16a1 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 @@ -309,15 +310,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 @@ -2206,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 @@ -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 @@ -2740,10 +2741,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"] -> @@ -2759,6 +2766,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 @@ -2954,22 +3068,35 @@ 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 = conn.client_settings.max_header_list_size + max_block_size = div(max_size * 30, 8) + @max_dynamic_table_size_updates_size - 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) + 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" - _max_size -> - conn + 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 2a80d667..60495c24 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) @@ -1052,10 +1064,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 +1098,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 +1332,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 +1418,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, [ @@ -2458,6 +2573,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", @@ -2510,6 +2649,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}, @@ -3844,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 @@ -4064,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) @@ -4766,6 +5035,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)