Skip to content

Fix HTTP/2 header block bound, push validation and CONNECT 204 - #522

Open
ericmj wants to merge 5 commits into
mainfrom
http2-followups
Open

ericmj wants to merge 5 commits into
mainfrom
http2-followups

Conversation

@ericmj

@ericmj ericmj commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Five fixes to HTTP/2 header and push handling, one commit each, meant to be rebase-merged rather than squashed.

  • The compressed header block buffered while waiting for CONTINUATION was bounded by max_header_list_size itself. Huffman coding can make a block larger than the list it decodes to (RFC 7541 5.2), so a list within the limit was a connection error when split across frames and accepted in one frame. The bound is now max_header_list_size * 30 / 8 + 12, an upper bound on the encoded size of a list within the limit when integers are minimally encoded and there are at most two table size updates, which is what a conforming encoder sends. The fragment that ends the block isn't counted. The size limit itself is enforced on the decoded list.
  • The :max_header_list_size docs said an oversized header block closes the connection. They now describe the stream errors for oversized responses and pushes, and the buffered-block bound. The docs also say the client value can't be :infinity.
  • Promised requests have their :scheme, :authority and :path checked against RFC 3986, and userinfo in :authority is rejected for http and https (RFC 9113 8.3.1). Invalid ones reset the promised stream.
  • The client max_header_list_size is always an integer, so the :infinity branches for it are removed.
  • A 204 response to CONNECT is treated as an established tunnel like any other 2xx (RFC 9110 9.3.6, RFC 9113 8.5). Before, DATA after it was a protocol error.

@coveralls

coveralls commented Sep 28, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 0

Coverage increased (+0.3%) to 90.805%

Details

  • Coverage increased (+0.3%) from the base build.
  • Patch coverage: 2 uncovered changes across 1 file (52 of 54 lines covered, 96.3%).
  • 2 coverage regressions across 1 file.

Uncovered Changes

File Changed Covered %
lib/mint/http2.ex 54 52 96.3%

Coverage Regressions

2 previously-covered lines in 1 file lost coverage.

File Lines Losing Coverage Coverage
lib/mint/http2.ex 2 95.97%

Coverage Stats

Coverage Status
Relevant Lines: 2012
Covered Lines: 1827
Line Coverage: 90.81%
Coverage Strength: 717.04 hits per line

馃挍 - Coveralls

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.
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.
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.
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.
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.
@ericmj
ericmj marked this pull request as ready for review September 28, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants