diff --git a/gunicorn/asgi/protocol.py b/gunicorn/asgi/protocol.py index 789fbc46..fe9b610d 100644 --- a/gunicorn/asgi/protocol.py +++ b/gunicorn/asgi/protocol.py @@ -905,6 +905,8 @@ class ASGIProtocol(asyncio.Protocol): response_complete = False exc_to_raise = None use_chunked = False + omits_body = False + omits_body_warned = False # Reset response buffer for write batching self._response_buffer = None @@ -919,7 +921,8 @@ class ASGIProtocol(asyncio.Protocol): async def send(message): nonlocal response_started, response_complete, exc_to_raise - nonlocal response_status, response_headers, response_sent, use_chunked + nonlocal response_status, response_headers, response_sent, use_chunked, omits_body + nonlocal omits_body_warned # If client disconnected, silently ignore send attempts # This allows apps to finish cleanup without errors @@ -954,15 +957,26 @@ 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. + 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 + has_transfer_encoding = False + use_chunked = False + # Use chunked encoding for HTTP/1.1 streaming responses without Content-Length. - # Skip when the response cannot carry a body (HEAD/1xx/204/304) or when - # Transfer-Encoding was already set by the framework. - is_no_body = self._response_omits_body(request.method, response_status) + # Skip when the response cannot carry a body or when Transfer-Encoding was + # already set by the framework. needs_chunked = ( not has_content_length and not has_transfer_encoding and request.version >= (1, 1) - and not is_no_body + and not omits_body ) if needs_chunked: use_chunked = True @@ -981,6 +995,22 @@ class ASGIProtocol(asyncio.Protocol): body = message.get("body", b"") more_body = message.get("more_body", False) + # RFC 9110: HEAD/1xx/204/304 responses must not carry a body, + # even if the framework emits one. Drop body bytes; + # use_chunked has already been forced False above so no + # terminator will be written either. Warn once per request + # so framework bugs surface in logs without spamming on + # multi-chunk streams. + if omits_body: + if body and not omits_body_warned: + self.log.warning( + "ASGI app sent body bytes on a no-body response " + "(method=%s status=%s); dropping per RFC 9110.", + request.method, response_status, + ) + omits_body_warned = True + body = b"" + if body: self._send_body(body, chunked=use_chunked) response_sent += len(body) @@ -1267,6 +1297,22 @@ class ASGIProtocol(asyncio.Protocol): or 100 <= status < 200 ) + @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. + """ + forbidden = (b"content-length", "content-length", + b"transfer-encoding", "transfer-encoding") + return [ + (n, v) for n, v in headers + if (n.lower() if isinstance(n, str) else n.lower()) not in forbidden + ] + def _send_body(self, body, chunked=False): """Send response body chunk. diff --git a/tests/test_asgi_streaming.py b/tests/test_asgi_streaming.py index 338eba10..270ad714 100644 --- a/tests/test_asgi_streaming.py +++ b/tests/test_asgi_streaming.py @@ -381,6 +381,38 @@ class TestResponseOmitsBody: assert self._omits("GET", 404) is False +class TestStripBodyFramingHeaders: + """Verify Content-Length and Transfer-Encoding are stripped for no-body + responses, regardless of header name casing or bytes/str typing.""" + + def _strip(self, headers): + from gunicorn.asgi.protocol import ASGIProtocol + return ASGIProtocol._strip_body_framing_headers(headers) + + def test_strips_lowercase_bytes(self): + result = self._strip([ + (b"content-type", b"text/plain"), + (b"content-length", b"5"), + (b"transfer-encoding", b"chunked"), + ]) + assert result == [(b"content-type", b"text/plain")] + + def test_strips_mixed_case_str(self): + result = self._strip([ + ("Content-Type", "text/plain"), + ("Content-Length", "5"), + ("Transfer-Encoding", "chunked"), + ]) + assert result == [("Content-Type", "text/plain")] + + def test_preserves_unrelated_headers(self): + headers = [ + (b"x-custom", b"value"), + (b"server", b"gunicorn"), + ] + assert self._strip(headers) == headers + + # ============================================================================ # Streaming Response Message Sequence Tests # ============================================================================