From 08902f53a29c0c6a9ab27c83a492907608e84c48 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:44:26 +0800 Subject: [PATCH] Accept 204/304 responses with content-length and no body RFC 9113 section 8.1.1 lists 204, 304 and the response to HEAD as messages defined to have no content, and allows a non-zero content-length. Only HEAD was exempt, so a 304 or 204 with content-length was accepted when END_STREAM was on HEADERS but rejected with an empty DATA frame. Concretize the header block once and apply the same exemption to 204/304. --- src/h2/stream.py | 13 ++++- tests/test_bodyless_responses.py | 81 ++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/test_bodyless_responses.py diff --git a/src/h2/stream.py b/src/h2/stream.py index 249f73e0c..cd72423f6 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -54,6 +54,14 @@ from .config import H2Configuration +def _is_bodyless_response(headers: Iterable[Header]) -> bool: + """Whether a response status is defined by RFC 9113 to have no content.""" + for name, value in headers: + if name in (b":status", ":status"): + return value in (b"204", b"304", "204", "304") + return False + + class StreamState(IntEnum): IDLE = 0 RESERVED_REMOTE = 1 @@ -1069,6 +1077,9 @@ def receive_headers(self, """ Receive a set of headers (or trailers). """ + # Concretize once: the same block is inspected for informational status, + # content-length, and header validation below. + headers = list(headers) if is_informational_response(headers): if end_stream: msg = "Cannot set END_STREAM on informational responses" @@ -1360,7 +1371,7 @@ def _initialize_content_length(self, headers: Iterable[Header]) -> None: _expected_content_length field from it. It's not an error for no Content-Length header to be present. """ - if self.request_method == b"HEAD": + if self.request_method == b"HEAD" or _is_bodyless_response(headers): self._expected_content_length = 0 return diff --git a/tests/test_bodyless_responses.py b/tests/test_bodyless_responses.py new file mode 100644 index 000000000..cffd2069d --- /dev/null +++ b/tests/test_bodyless_responses.py @@ -0,0 +1,81 @@ +"""RFC 9113 bodyless responses may carry a non-zero content-length.""" + +from __future__ import annotations + +import pytest + +import h2.connection +import h2.events +import h2.exceptions + +REQUEST_HEADERS = [ + (b":authority", b"example.com"), + (b":path", b"/"), + (b":scheme", b"https"), + (b":method", b"POST"), +] + + +def _open_stream(frame_factory) -> h2.connection.H2Connection: + connection = h2.connection.H2Connection() + connection.initiate_connection() + connection.send_headers(1, REQUEST_HEADERS, end_stream=True) + return connection + + +@pytest.mark.parametrize("status", [b"204", b"304"]) +def test_bodyless_response_accepts_content_length_with_empty_data( + frame_factory, status: bytes +) -> None: + connection = _open_stream(frame_factory) + headers = frame_factory.build_headers_frame( + [(b":status", status), (b"content-length", b"1234")] + ) + events = connection.receive_data(headers.serialize()) + assert isinstance(events[0], h2.events.ResponseReceived) + + data = frame_factory.build_data_frame(b"", flags=["END_STREAM"]) + events = connection.receive_data(data.serialize()) + + assert isinstance(events[0], h2.events.DataReceived) + assert isinstance(events[1], h2.events.StreamEnded) + + +@pytest.mark.parametrize("status", [b"204", b"304"]) +def test_bodyless_response_accepts_content_length_when_headers_end_stream( + frame_factory, status: bytes +) -> None: + connection = _open_stream(frame_factory) + headers = frame_factory.build_headers_frame( + [(b":status", status), (b"content-length", b"1234")], + flags=["END_STREAM"], + ) + + events = connection.receive_data(headers.serialize()) + + assert isinstance(events[0], h2.events.ResponseReceived) + assert isinstance(events[1], h2.events.StreamEnded) + + +def test_200_with_content_length_and_empty_data_is_still_rejected(frame_factory) -> None: + connection = _open_stream(frame_factory) + headers = frame_factory.build_headers_frame( + [(b":status", b"200"), (b"content-length", b"1234")] + ) + connection.receive_data(headers.serialize()) + + data = frame_factory.build_data_frame(b"", flags=["END_STREAM"]) + with pytest.raises(h2.exceptions.InvalidBodyLengthError): + connection.receive_data(data.serialize()) + + +def test_bodyless_response_with_actual_data_is_rejected(frame_factory) -> None: + connection = _open_stream(frame_factory) + headers = frame_factory.build_headers_frame( + [(b":status", b"304"), (b"content-length", b"1234")] + ) + connection.receive_data(headers.serialize()) + + data = frame_factory.build_data_frame(b"\x01", flags=["END_STREAM"]) + with pytest.raises(h2.exceptions.InvalidBodyLengthError): + connection.receive_data(data.serialize())