From a49f8eb59641b2f1bf2b5ecb571db37fa089fdb1 Mon Sep 17 00:00:00 2001 From: Harshal Parekh Date: Thu, 14 May 2026 11:00:55 -0500 Subject: [PATCH 1/5] fix: reject conflicting content-length headers --- src/h2/stream.py | 13 ++++- tests/test_invalid_content_lengths.py | 79 ++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/h2/stream.py b/src/h2/stream.py index 9b5bce789..3a6887d42 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -1364,15 +1364,24 @@ def _initialize_content_length(self, headers: Iterable[Header]) -> None: self._expected_content_length = 0 return + content_lengths = [] + for n, v in headers: if n == b"content-length": try: - self._expected_content_length = int(v, 10) + content_lengths.append(int(v, 10)) except ValueError as err: msg = f"Invalid content-length header: {v!r}" raise ProtocolError(msg) from err - return + if not content_lengths: + return + + if len(set(content_lengths)) != 1: + msg = "Conflicting content-length headers" + raise ProtocolError(msg) + + self._expected_content_length = content_lengths[0] def _track_content_length(self, length: int, end_stream: bool) -> None: """ diff --git a/tests/test_invalid_content_lengths.py b/tests/test_invalid_content_lengths.py index 39401ea2b..430b21cfb 100644 --- a/tests/test_invalid_content_lengths.py +++ b/tests/test_invalid_content_lengths.py @@ -19,18 +19,24 @@ class TestInvalidContentLengths: peer is not valid. """ - example_request_headers = [ + example_request_headers_without_content_length = [ (":authority", "example.com"), (":path", "/"), (":scheme", "https"), (":method", "POST"), + ] + example_request_headers = [ + *example_request_headers_without_content_length, ("content-length", "15"), ] - example_request_headers_bytes = [ + example_request_headers_bytes_without_content_length = [ (b":authority", b"example.com"), (b":path", b"/"), (b":scheme", b"https"), (b":method", b"POST"), + ] + example_request_headers_bytes = [ + *example_request_headers_bytes_without_content_length, (b"content-length", b"15"), ] example_response_headers = [ @@ -39,6 +45,75 @@ class TestInvalidContentLengths: ] server_config = h2.config.H2Configuration(client_side=False) + @pytest.mark.parametrize( + "request_headers", + [ + example_request_headers_without_content_length, + example_request_headers_bytes_without_content_length, + ], + ) + def test_duplicate_matching_content_lengths(self, frame_factory, request_headers) -> None: + """ + Remote peers sending duplicate matching content-length fields are + accepted. + """ + c = h2.connection.H2Connection(config=self.server_config) + c.initiate_connection() + c.receive_data(frame_factory.preamble()) + c.clear_outbound_data_buffer() + + headers = frame_factory.build_headers_frame( + headers=[ + *request_headers, + ("content-length", "15"), + ("content-length", "15"), + ], + ) + data = frame_factory.build_data_frame( + data=b"\x01"*15, + flags=["END_STREAM"], + ) + + events = c.receive_data(headers.serialize() + data.serialize()) + + assert isinstance(events[0], h2.events.RequestReceived) + assert isinstance(events[1], h2.events.DataReceived) + assert isinstance(events[2], h2.events.StreamEnded) + assert c.data_to_send() == b"" + + @pytest.mark.parametrize( + "request_headers", + [ + example_request_headers_without_content_length, + example_request_headers_bytes_without_content_length, + ], + ) + def test_duplicate_conflicting_content_lengths(self, frame_factory, request_headers) -> None: + """ + Remote peers sending duplicate conflicting content-length fields cause + Protocol Errors. + """ + c = h2.connection.H2Connection(config=self.server_config) + c.initiate_connection() + c.receive_data(frame_factory.preamble()) + c.clear_outbound_data_buffer() + + headers = frame_factory.build_headers_frame( + headers=[ + *request_headers, + ("content-length", "15"), + ("content-length", "16"), + ], + ) + with pytest.raises(h2.exceptions.ProtocolError): + c.receive_data(headers.serialize()) + + expected_frame = frame_factory.build_goaway_frame( + last_stream_id=1, + error_code=h2.errors.ErrorCodes.PROTOCOL_ERROR, + ) + assert c.data_to_send() == expected_frame.serialize() + @pytest.mark.parametrize("request_headers", [example_request_headers, example_request_headers_bytes]) def test_too_much_data(self, frame_factory, request_headers) -> None: """ From a0b95621ab00d74fb27738d4f37868fbec8416b0 Mon Sep 17 00:00:00 2001 From: Harshal Parekh Date: Thu, 14 May 2026 21:28:02 -0500 Subject: [PATCH 2/5] Update stream.py --- src/h2/stream.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/h2/stream.py b/src/h2/stream.py index 3a6887d42..6ff1d8dd2 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -1364,24 +1364,26 @@ def _initialize_content_length(self, headers: Iterable[Header]) -> None: self._expected_content_length = 0 return - content_lengths = [] + content_length = None for n, v in headers: if n == b"content-length": try: - content_lengths.append(int(v, 10)) + parsed_content_length = int(v, 10) except ValueError as err: msg = f"Invalid content-length header: {v!r}" raise ProtocolError(msg) from err - if not content_lengths: - return + if content_length is None: + content_length = parsed_content_length + elif parsed_content_length != content_length: + msg = "Conflicting content-length headers" + raise ProtocolError(msg) - if len(set(content_lengths)) != 1: - msg = "Conflicting content-length headers" - raise ProtocolError(msg) + if content_length is None: + return - self._expected_content_length = content_lengths[0] + self._expected_content_length = content_length def _track_content_length(self, length: int, end_stream: bool) -> None: """ From 99895d10efeb8ceab5285e5d94a3c2ff43b4143a Mon Sep 17 00:00:00 2001 From: Harshal Parekh Date: Mon, 18 May 2026 16:35:17 -0500 Subject: [PATCH 3/5] include both parsed values in the ProtocolError message --- src/h2/stream.py | 2 +- tests/test_invalid_content_lengths.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/h2/stream.py b/src/h2/stream.py index 6ff1d8dd2..e72f5b2d8 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -1377,7 +1377,7 @@ def _initialize_content_length(self, headers: Iterable[Header]) -> None: if content_length is None: content_length = parsed_content_length elif parsed_content_length != content_length: - msg = "Conflicting content-length headers" + msg = f"Conflicting content-length headers: {content_length} and {parsed_content_length}" raise ProtocolError(msg) if content_length is None: diff --git a/tests/test_invalid_content_lengths.py b/tests/test_invalid_content_lengths.py index 430b21cfb..4242c94e4 100644 --- a/tests/test_invalid_content_lengths.py +++ b/tests/test_invalid_content_lengths.py @@ -105,7 +105,10 @@ def test_duplicate_conflicting_content_lengths(self, frame_factory, request_head ("content-length", "16"), ], ) - with pytest.raises(h2.exceptions.ProtocolError): + with pytest.raises( + h2.exceptions.ProtocolError, + match="Conflicting content-length headers: 15 and 16", + ): c.receive_data(headers.serialize()) expected_frame = frame_factory.build_goaway_frame( From 5f67e0350a9370bd67a2a727397f7f24da9003be Mon Sep 17 00:00:00 2001 From: Harshal Parekh Date: Mon, 18 May 2026 16:38:21 -0500 Subject: [PATCH 4/5] refactor: simplify content length initialization --- src/h2/stream.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/h2/stream.py b/src/h2/stream.py index e72f5b2d8..f8dc2bf2e 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -1380,9 +1380,6 @@ def _initialize_content_length(self, headers: Iterable[Header]) -> None: msg = f"Conflicting content-length headers: {content_length} and {parsed_content_length}" raise ProtocolError(msg) - if content_length is None: - return - self._expected_content_length = content_length def _track_content_length(self, length: int, end_stream: bool) -> None: From bffb965508ae1a9f60f9b7ddb2fb1d71c2a68549 Mon Sep 17 00:00:00 2001 From: Harshal Parekh Date: Mon, 18 May 2026 16:56:01 -0500 Subject: [PATCH 5/5] docs: document conflicting content length rejection --- CHANGELOG.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 775c385e3..9b65bf7ed 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,8 @@ dev - Support for Python 3.9 has been removed. - Support for PyPy 3.9 has been removed. - `Stream.end_stream()` now raises `NoSuchStreamError` or `StreamClosedError` exceptions, instead of a generic `KeyError`. +- Duplicate ``content-length`` headers with different values now raise ``ProtocolError``. + Previously, the first ``content-length`` header was accepted and later conflicting values were ignored. - **backfill from v4.3.0** Convert emitted events into Python `dataclass`, which introduces new constructors with required arguments. Instantiating these events without arguments, as previously commonly used API pattern, will no longer work.