Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/h2/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -1365,8 +1365,12 @@ def _initialize_content_length(self, headers: Iterable[Header]) -> None:
return

content_length = None
response_status: bytes | None = None

for n, v in headers:
if n == b":status":
response_status = v

if n == b"content-length":
if not v.isdigit():
# https://www.rfc-editor.org/rfc/rfc9110.html#name-content-length
Expand All @@ -1385,7 +1389,12 @@ def _initialize_content_length(self, headers: Iterable[Header]) -> None:
msg = f"Conflicting content-length headers: {content_length} and {parsed_content_length}"
raise ProtocolError(msg)

self._expected_content_length = content_length
# These responses have no content, so Content-Length is not the size
# of any DATA frames (RFC 9113, § 8.1.1).
if response_status in {b"204", b"304"}:
self._expected_content_length = 0
else:
self._expected_content_length = content_length

def _track_content_length(self, length: int, end_stream: bool) -> None:
"""
Expand Down
53 changes: 53 additions & 0 deletions tests/test_invalid_content_lengths.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,59 @@ def test_content_length_with_non_digit_value(self, frame_factory, value) -> None
)
assert c.data_to_send() == expected_frame.serialize()

@pytest.mark.parametrize("status", ["204", "304"])
@pytest.mark.parametrize("end_stream_on_headers", [True, False])
def test_no_content_response_with_content_length(self, frame_factory, status, end_stream_on_headers) -> None:
"""Content-Length does not imply a body for a no-content response."""
c = h2.connection.H2Connection()
c.initiate_connection()
c.send_headers(
1,
[(":method", "GET"), (":scheme", "https"), (":authority", "example.com"), (":path", "/")],
end_stream=True,
)

headers = frame_factory.build_headers_frame(
headers=[(":status", status), ("content-length", "15")],
flags=["END_STREAM"] if end_stream_on_headers else [],
)
frames = headers.serialize()
if not end_stream_on_headers:
data = frame_factory.build_data_frame(data=b"", flags=["END_STREAM"])
frames += data.serialize()

events = c.receive_data(frames)
assert isinstance(events[0], h2.events.ResponseReceived)
assert isinstance(events[-1], h2.events.StreamEnded)

@pytest.mark.parametrize(
"response_headers",
[
[(":status", "204")],
[(":status", "204"), ("content-length", "15")],
[(":status", "304"), ("content-length", "15")],
],
)
def test_no_content_response_rejects_content(self, frame_factory, response_headers) -> None:
"""204 and 304 responses have no content, regardless of Content-Length."""
c = h2.connection.H2Connection()
c.initiate_connection()
c.send_headers(
1,
[(":method", "GET"), (":scheme", "https"), (":authority", "example.com"), (":path", "/")],
end_stream=True,
)

headers = frame_factory.build_headers_frame(
headers=response_headers,
)
data = frame_factory.build_data_frame(data=b"x", flags=["END_STREAM"])
with pytest.raises(h2.exceptions.InvalidBodyLengthError) as exp:
c.receive_data(headers.serialize() + data.serialize())

assert exp.value.expected_length == 0
assert exp.value.actual_length == 1

@pytest.mark.parametrize(
"request_headers",
[
Expand Down