diff --git a/gunicorn/asgi/protocol.py b/gunicorn/asgi/protocol.py index 085a4604..8e488c39 100644 --- a/gunicorn/asgi/protocol.py +++ b/gunicorn/asgi/protocol.py @@ -970,15 +970,18 @@ class ASGIProtocol(asyncio.Protocol): has_transfer_encoding = True use_chunked = True # Framework already set chunked encoding - # RFC 9110 forbids a body for HEAD requests and for 1xx/204/304 - # status codes. When the framework supplied Content-Length or - # Transfer-Encoding for such a response, drop them and force - # plain framing so we never emit a chunked terminator or a - # framework-supplied body. + # No-body responses (HEAD/1xx/204/304) must not carry a body. + # Always drop Transfer-Encoding (no chunked terminator without + # a body); Content-Length is dropped only for statuses that + # forbid it per RFC 9110 §6.4.2 (1xx, 204). HEAD and 304 keep + # an app-supplied Content-Length. omits_body = self._response_omits_body(request.method, response_status) if omits_body and (has_content_length or has_transfer_encoding): - response_headers = self._strip_body_framing_headers(response_headers) - has_content_length = False + response_headers = self._strip_body_framing_headers( + response_headers, response_status + ) + if self._response_forbids_content_length(response_status): + has_content_length = False has_transfer_encoding = False use_chunked = False @@ -1313,16 +1316,25 @@ class ASGIProtocol(asyncio.Protocol): ) @staticmethod - def _strip_body_framing_headers(headers): - """Remove Content-Length and Transfer-Encoding from a header list. - - Used when a response cannot carry a body (HEAD/1xx/204/304); RFC 9110 - forbids a body and a framework-supplied framing header would either - mislead the peer about the response shape or leave us emitting a - chunked terminator the peer must not see. + def _response_forbids_content_length(status): + """Per RFC 9110 §6.4.2 a server MUST NOT send Content-Length on 1xx + or 204 responses. HEAD and 304 are NOT covered: HEAD MAY include the + Content-Length the same GET would have returned, and 304 MAY include + the Content-Length the unconditional response would have carried. """ - forbidden = (b"content-length", "content-length", - b"transfer-encoding", "transfer-encoding") + return status == 204 or 100 <= status < 200 + + @classmethod + def _strip_body_framing_headers(cls, headers, status): + """Remove framing headers that must not appear on a no-body response. + + Transfer-Encoding is always stripped (chunked framing implies a body + we will not send). Content-Length is stripped only when the status + forbids it (1xx / 204); HEAD and 304 keep app-supplied Content-Length. + """ + forbidden = {b"transfer-encoding", "transfer-encoding"} + if cls._response_forbids_content_length(status): + forbidden.update({b"content-length", "content-length"}) return [ (n, v) for n, v in headers if (n.lower() if isinstance(n, str) else n.lower()) not in forbidden diff --git a/tests/test_asgi_streaming.py b/tests/test_asgi_streaming.py index 270ad714..7e3b9211 100644 --- a/tests/test_asgi_streaming.py +++ b/tests/test_asgi_streaming.py @@ -382,35 +382,75 @@ class TestResponseOmitsBody: class TestStripBodyFramingHeaders: - """Verify Content-Length and Transfer-Encoding are stripped for no-body - responses, regardless of header name casing or bytes/str typing.""" + """Verify the framing-header strip honours RFC 9110 §6.4.2: + Transfer-Encoding is always stripped on no-body responses; Content-Length + is stripped only when the status forbids it (1xx, 204), not for HEAD or 304. + """ - def _strip(self, headers): + def _strip(self, headers, status): from gunicorn.asgi.protocol import ASGIProtocol - return ASGIProtocol._strip_body_framing_headers(headers) + return ASGIProtocol._strip_body_framing_headers(headers, status) - def test_strips_lowercase_bytes(self): + def test_204_strips_both_lowercase_bytes(self): result = self._strip([ (b"content-type", b"text/plain"), (b"content-length", b"5"), (b"transfer-encoding", b"chunked"), - ]) + ], 204) assert result == [(b"content-type", b"text/plain")] - def test_strips_mixed_case_str(self): + def test_103_strips_both_mixed_case_str(self): result = self._strip([ ("Content-Type", "text/plain"), ("Content-Length", "5"), ("Transfer-Encoding", "chunked"), - ]) + ], 103) assert result == [("Content-Type", "text/plain")] + def test_304_keeps_content_length_strips_te(self): + result = self._strip([ + (b"etag", b"\"abc\""), + (b"content-length", b"42"), + (b"transfer-encoding", b"chunked"), + ], 304) + assert result == [(b"etag", b"\"abc\""), (b"content-length", b"42")] + + def test_head_response_keeps_content_length_strips_te(self): + # The caller passes the response status; HEAD responses are detected + # via request.method, but the strip itself only sees status. Verify + # a 200 status preserves Content-Length even though the strip is + # invoked for a HEAD request. + result = self._strip([ + (b"content-length", b"1024"), + (b"transfer-encoding", b"chunked"), + ], 200) + assert result == [(b"content-length", b"1024")] + def test_preserves_unrelated_headers(self): - headers = [ - (b"x-custom", b"value"), - (b"server", b"gunicorn"), - ] - assert self._strip(headers) == headers + headers = [(b"x-custom", b"value"), (b"server", b"gunicorn")] + assert self._strip(headers, 204) == headers + + +class TestResponseForbidsContentLength: + """Verify the 1xx/204 forbid-rule (RFC 9110 §6.4.2) is encoded correctly.""" + + def _forbids(self, status): + from gunicorn.asgi.protocol import ASGIProtocol + return ASGIProtocol._response_forbids_content_length(status) + + def test_204(self): + assert self._forbids(204) is True + + def test_1xx(self): + assert self._forbids(100) is True + assert self._forbids(103) is True + assert self._forbids(199) is True + + def test_304_allowed(self): + assert self._forbids(304) is False + + def test_200_allowed(self): + assert self._forbids(200) is False # ============================================================================