From 4aa3d90a7616c798b55032da95e633a002074dee Mon Sep 17 00:00:00 2001 From: Eirik Botten Nicolaysen Date: Mon, 31 Aug 2026 13:01:50 +0200 Subject: [PATCH 1/2] Reject empty header names on the outbound path The inbound pipeline runs _reject_empty_header_names before _reject_pseudo_header_fields, so an empty name is caught before the `header[0][0]` lookup. The outbound pipeline has no such guard, so sending a header block with an empty name raises IndexError from utilities.py:335 instead of ProtocolError. Add the guard to validate_outbound_headers in the same position. The message differs by direction, so the body is shared by _validate_nonempty_header_names, mirroring how _check_host_authority_header and _check_sent_host_authority_header already share _validate_host_authority_header. --- src/h2/utilities.py | 32 +++++++++++++++++++++++++------- tests/test_invalid_headers.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/h2/utilities.py b/src/h2/utilities.py index c4e62f2f3..051dcd384 100644 --- a/src/h2/utilities.py +++ b/src/h2/utilities.py @@ -265,21 +265,36 @@ def _reject_illegal_characters(headers: Iterable[Header], yield header -def _reject_empty_header_names(headers: Iterable[Header], - hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: +def _validate_nonempty_header_names(headers: Iterable[Header], msg: str) -> Generator[Header, None, None]: """ - Raises a ProtocolError if any header names are empty (length 0). - While hpack decodes such headers without errors, they are semantically - forbidden in HTTP, see RFC 7230, stating that they must be at least one - character long. + Raises a ProtocolError with the given message if any header name is empty + (length 0). While hpack decodes such headers without errors, they are + semantically forbidden in HTTP, see RFC 7230, stating that they must be at + least one character long. """ for header in headers: if len(header[0]) == 0: - msg = "Received header name with zero length." raise ProtocolError(msg) yield header +def _reject_empty_header_names(headers: Iterable[Header], + hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: + """ + Raises a ProtocolError if a header block arrives with an empty header name. + """ + return _validate_nonempty_header_names(headers, "Received header name with zero length.") + + +def _reject_sent_empty_header_names(headers: Iterable[Header], + hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: + """ + Raises a ProtocolError if we try to send a header block with an empty + header name. + """ + return _validate_nonempty_header_names(headers, "Sent header name with zero length.") + + def _reject_te(headers: Iterable[Header], hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: """ Raises a ProtocolError if the TE header is present in a header block and @@ -690,6 +705,9 @@ def validate_outbound_headers(headers: Iterable[Header], :param headers: The HTTP header set. :param hdr_validation_flags: An instance of HeaderValidationFlags. """ + headers = _reject_sent_empty_header_names( + headers, hdr_validation_flags, + ) headers = _reject_te( headers, hdr_validation_flags, ) diff --git a/tests/test_invalid_headers.py b/tests/test_invalid_headers.py index 2a68c6fc6..83ed80932 100644 --- a/tests/test_invalid_headers.py +++ b/tests/test_invalid_headers.py @@ -672,6 +672,37 @@ def test_inbound_header_name_length(self, hdr_validation_flags) -> None: with pytest.raises(h2.exceptions.ProtocolError): list(h2.utilities.validate_headers([(b"", b"foobar")], hdr_validation_flags)) + @pytest.mark.parametrize("hdr_validation_flags", hdr_validation_combos) + def test_outbound_header_name_length(self, hdr_validation_flags) -> None: + # An empty outbound header name must raise ProtocolError, not IndexError + # from the `header[0][0]` lookup in _reject_pseudo_header_fields. + with pytest.raises(h2.exceptions.ProtocolError): + list(h2.utilities.validate_outbound_headers([(b"", b"foobar")], hdr_validation_flags)) + + def test_outbound_header_name_length_send_headers(self, frame_factory) -> None: + c = h2.connection.H2Connection() + c.initiate_connection() + c.clear_outbound_data_buffer() + + headers = [ + (b":authority", b"example.com"), + (b":path", b"/"), + (b":scheme", b"https"), + (b":method", b"GET"), + (b"", b"foobar"), + ] + with pytest.raises(h2.exceptions.ProtocolError, match=r"Sent header name with zero length\."): + c.send_headers(1, headers) + + @pytest.mark.parametrize("hdr_validation_flags", [ + flags for flags in hdr_validation_combos + if flags.is_trailer and not flags.is_response_header + ]) + def test_valid_header_name_accepted_both_directions(self, hdr_validation_flags) -> None: + headers = [(b"x-custom-header", b"foobar")] + assert list(h2.utilities.validate_headers(list(headers), hdr_validation_flags)) + assert list(h2.utilities.validate_outbound_headers(list(headers), hdr_validation_flags)) + def test_inbound_header_name_length_full_frame_decode(self, frame_factory) -> None: f = frame_factory.build_headers_frame([]) f.data = b"\x00\x00\x01\x04" From ae42b9ac67895a86119196abbd1c2fce83fc4dba Mon Sep 17 00:00:00 2001 From: Eirik Botten Nicolaysen Date: Mon, 31 Aug 2026 14:02:19 +0200 Subject: [PATCH 2/2] Unify empty header name rejection into one generator Use a single generator for both directions, with a direction-neutral message matching the style of the other checks that run on both paths. Update the inbound test to the new wording. --- src/h2/utilities.py | 31 ++++++++----------------------- tests/test_invalid_headers.py | 4 ++-- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/src/h2/utilities.py b/src/h2/utilities.py index 051dcd384..4a3ff823a 100644 --- a/src/h2/utilities.py +++ b/src/h2/utilities.py @@ -265,36 +265,21 @@ def _reject_illegal_characters(headers: Iterable[Header], yield header -def _validate_nonempty_header_names(headers: Iterable[Header], msg: str) -> Generator[Header, None, None]: +def _reject_empty_header_names(headers: Iterable[Header], + hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: """ - Raises a ProtocolError with the given message if any header name is empty - (length 0). While hpack decodes such headers without errors, they are - semantically forbidden in HTTP, see RFC 7230, stating that they must be at - least one character long. + Raises a ProtocolError if any header names are empty (length 0). + While hpack decodes such headers without errors, they are semantically + forbidden in HTTP, see RFC 7230, stating that they must be at least one + character long. """ for header in headers: if len(header[0]) == 0: + msg = "Header name with zero length present." raise ProtocolError(msg) yield header -def _reject_empty_header_names(headers: Iterable[Header], - hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: - """ - Raises a ProtocolError if a header block arrives with an empty header name. - """ - return _validate_nonempty_header_names(headers, "Received header name with zero length.") - - -def _reject_sent_empty_header_names(headers: Iterable[Header], - hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: - """ - Raises a ProtocolError if we try to send a header block with an empty - header name. - """ - return _validate_nonempty_header_names(headers, "Sent header name with zero length.") - - def _reject_te(headers: Iterable[Header], hdr_validation_flags: HeaderValidationFlags) -> Generator[Header, None, None]: """ Raises a ProtocolError if the TE header is present in a header block and @@ -705,7 +690,7 @@ def validate_outbound_headers(headers: Iterable[Header], :param headers: The HTTP header set. :param hdr_validation_flags: An instance of HeaderValidationFlags. """ - headers = _reject_sent_empty_header_names( + headers = _reject_empty_header_names( headers, hdr_validation_flags, ) headers = _reject_te( diff --git a/tests/test_invalid_headers.py b/tests/test_invalid_headers.py index 83ed80932..44daa07e8 100644 --- a/tests/test_invalid_headers.py +++ b/tests/test_invalid_headers.py @@ -691,7 +691,7 @@ def test_outbound_header_name_length_send_headers(self, frame_factory) -> None: (b":method", b"GET"), (b"", b"foobar"), ] - with pytest.raises(h2.exceptions.ProtocolError, match=r"Sent header name with zero length\."): + with pytest.raises(h2.exceptions.ProtocolError, match=r"Header name with zero length present\."): c.send_headers(1, headers) @pytest.mark.parametrize("hdr_validation_flags", [ @@ -713,7 +713,7 @@ def test_inbound_header_name_length_full_frame_decode(self, frame_factory) -> No c.receive_data(frame_factory.preamble()) c.clear_outbound_data_buffer() - with pytest.raises(h2.exceptions.ProtocolError, match="Received header name with zero length."): + with pytest.raises(h2.exceptions.ProtocolError, match=r"Header name with zero length present\."): c.receive_data(data)