Merge pull request #3616 from benoitc/fix/asgi-protocol-review-followups

fix: drop body framing on HEAD/204/304 even when framework set it
This commit is contained in:
Benoit Chesneau 2026-05-03 20:38:57 +02:00 committed by GitHub
commit 02c53cfcfc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 83 additions and 5 deletions

View File

@ -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.

View File

@ -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
# ============================================================================