mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-02 18:51:31 +08:00
fix: keep Content-Length on HEAD and 304 responses
RFC 9110 §6.4.2 forbids Content-Length only on 1xx and 204 responses. HEAD MAY include the Content-Length the same GET would return, and 304 MAY include the Content-Length the unconditional response would carry. WSGI preserves app-supplied Content-Length on those statuses; ASGI was stripping it indiscriminately for any no-body response. Split the predicate: _response_forbids_content_length() returns True only for 1xx/204; _strip_body_framing_headers(headers, status) always strips Transfer-Encoding (no body, no chunked terminator) and strips Content-Length only when forbidden.
This commit is contained in:
parent
9902bc761c
commit
41ec7527db
@ -970,15 +970,18 @@ class ASGIProtocol(asyncio.Protocol):
|
|||||||
has_transfer_encoding = True
|
has_transfer_encoding = True
|
||||||
use_chunked = True # Framework already set chunked encoding
|
use_chunked = True # Framework already set chunked encoding
|
||||||
|
|
||||||
# RFC 9110 forbids a body for HEAD requests and for 1xx/204/304
|
# No-body responses (HEAD/1xx/204/304) must not carry a body.
|
||||||
# status codes. When the framework supplied Content-Length or
|
# Always drop Transfer-Encoding (no chunked terminator without
|
||||||
# Transfer-Encoding for such a response, drop them and force
|
# a body); Content-Length is dropped only for statuses that
|
||||||
# plain framing so we never emit a chunked terminator or a
|
# forbid it per RFC 9110 §6.4.2 (1xx, 204). HEAD and 304 keep
|
||||||
# framework-supplied body.
|
# an app-supplied Content-Length.
|
||||||
omits_body = self._response_omits_body(request.method, response_status)
|
omits_body = self._response_omits_body(request.method, response_status)
|
||||||
if omits_body and (has_content_length or has_transfer_encoding):
|
if omits_body and (has_content_length or has_transfer_encoding):
|
||||||
response_headers = self._strip_body_framing_headers(response_headers)
|
response_headers = self._strip_body_framing_headers(
|
||||||
has_content_length = False
|
response_headers, response_status
|
||||||
|
)
|
||||||
|
if self._response_forbids_content_length(response_status):
|
||||||
|
has_content_length = False
|
||||||
has_transfer_encoding = False
|
has_transfer_encoding = False
|
||||||
use_chunked = False
|
use_chunked = False
|
||||||
|
|
||||||
@ -1313,16 +1316,25 @@ class ASGIProtocol(asyncio.Protocol):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _strip_body_framing_headers(headers):
|
def _response_forbids_content_length(status):
|
||||||
"""Remove Content-Length and Transfer-Encoding from a header list.
|
"""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
|
||||||
Used when a response cannot carry a body (HEAD/1xx/204/304); RFC 9110
|
Content-Length the same GET would have returned, and 304 MAY include
|
||||||
forbids a body and a framework-supplied framing header would either
|
the Content-Length the unconditional response would have carried.
|
||||||
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",
|
return status == 204 or 100 <= status < 200
|
||||||
b"transfer-encoding", "transfer-encoding")
|
|
||||||
|
@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 [
|
return [
|
||||||
(n, v) for n, v in headers
|
(n, v) for n, v in headers
|
||||||
if (n.lower() if isinstance(n, str) else n.lower()) not in forbidden
|
if (n.lower() if isinstance(n, str) else n.lower()) not in forbidden
|
||||||
|
|||||||
@ -382,35 +382,75 @@ class TestResponseOmitsBody:
|
|||||||
|
|
||||||
|
|
||||||
class TestStripBodyFramingHeaders:
|
class TestStripBodyFramingHeaders:
|
||||||
"""Verify Content-Length and Transfer-Encoding are stripped for no-body
|
"""Verify the framing-header strip honours RFC 9110 §6.4.2:
|
||||||
responses, regardless of header name casing or bytes/str typing."""
|
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
|
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([
|
result = self._strip([
|
||||||
(b"content-type", b"text/plain"),
|
(b"content-type", b"text/plain"),
|
||||||
(b"content-length", b"5"),
|
(b"content-length", b"5"),
|
||||||
(b"transfer-encoding", b"chunked"),
|
(b"transfer-encoding", b"chunked"),
|
||||||
])
|
], 204)
|
||||||
assert result == [(b"content-type", b"text/plain")]
|
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([
|
result = self._strip([
|
||||||
("Content-Type", "text/plain"),
|
("Content-Type", "text/plain"),
|
||||||
("Content-Length", "5"),
|
("Content-Length", "5"),
|
||||||
("Transfer-Encoding", "chunked"),
|
("Transfer-Encoding", "chunked"),
|
||||||
])
|
], 103)
|
||||||
assert result == [("Content-Type", "text/plain")]
|
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):
|
def test_preserves_unrelated_headers(self):
|
||||||
headers = [
|
headers = [(b"x-custom", b"value"), (b"server", b"gunicorn")]
|
||||||
(b"x-custom", b"value"),
|
assert self._strip(headers, 204) == headers
|
||||||
(b"server", b"gunicorn"),
|
|
||||||
]
|
|
||||||
assert self._strip(headers) == 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
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user