mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 10:11:30 +08:00
fix: drop body framing on HEAD/1xx/204/304 in WSGI responses
Mirror the ASGI strip-and-warn behavior (commits 2191832b, 41ec7527, 0d35d2ae) on the WSGI path. Previously gunicorn would forward an app-supplied Content-Length and body bytes for HEAD requests and 1xx/204/304 responses, violating RFC 9110 / RFC 9112. - Add _response_omits_body() and _response_forbids_content_length() helpers on Response. - After process_headers, strip Content-Length and clear response_length on 1xx/204 (RFC 9110 §6.4.2 forbids it). HEAD and 304 keep app-supplied Content-Length. - write() and sendfile() drop body bytes for no-body responses and log a single WARNING per request. - is_chunked() now also covers 1xx via _omits_body. Fixes #3413
This commit is contained in:
parent
5d819cf360
commit
9bc5891b4b
@ -300,6 +300,8 @@ class Response:
|
||||
self.sent = 0
|
||||
self.upgrade = False
|
||||
self.cfg = cfg
|
||||
self._omits_body = False
|
||||
self._omits_body_warned = False
|
||||
|
||||
def force_close(self):
|
||||
self.must_close = True
|
||||
@ -336,9 +338,34 @@ class Response:
|
||||
self.status_code = None
|
||||
|
||||
self.process_headers(headers)
|
||||
self._omits_body = self._response_omits_body(
|
||||
self.req.method, self.status_code)
|
||||
if self._omits_body and self._response_forbids_content_length(
|
||||
self.status_code):
|
||||
self.headers = [
|
||||
(k, v) for k, v in self.headers if k.lower() != "content-length"
|
||||
]
|
||||
self.response_length = None
|
||||
self.chunked = self.is_chunked()
|
||||
return self.write
|
||||
|
||||
@staticmethod
|
||||
def _response_omits_body(method, status):
|
||||
# RFC 9110: HEAD requests and 1xx/204/304 responses MUST NOT carry
|
||||
# a body, regardless of what the application emits.
|
||||
return (
|
||||
method == "HEAD"
|
||||
or status in (204, 304)
|
||||
or (status is not None and 100 <= status < 200)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _response_forbids_content_length(status):
|
||||
# RFC 9110 §6.4.2: a server MUST NOT send Content-Length on 1xx or
|
||||
# 204. HEAD MAY include the Content-Length the same GET would carry,
|
||||
# and 304 MAY include the Content-Length of the unconditional response.
|
||||
return status == 204 or (status is not None and 100 <= status < 200)
|
||||
|
||||
def process_headers(self, headers):
|
||||
for name, value in headers:
|
||||
if not isinstance(name, str):
|
||||
@ -379,12 +406,8 @@ class Response:
|
||||
return False
|
||||
elif self.req.version <= (1, 0):
|
||||
return False
|
||||
elif self.req.method == 'HEAD':
|
||||
# Responses to a HEAD request MUST NOT contain a response body.
|
||||
return False
|
||||
elif self.status_code in (204, 304):
|
||||
# Do not use chunked responses when the response is guaranteed to
|
||||
# not have a response body.
|
||||
elif self._omits_body:
|
||||
# No body permitted (HEAD or 1xx/204/304), so no chunked framing.
|
||||
return False
|
||||
return True
|
||||
|
||||
@ -422,6 +445,15 @@ class Response:
|
||||
self.send_headers()
|
||||
if not isinstance(arg, bytes):
|
||||
raise TypeError('%r is not a byte' % arg)
|
||||
if self._omits_body:
|
||||
if arg and not self._omits_body_warned:
|
||||
log.warning(
|
||||
"WSGI app sent body bytes on a no-body response "
|
||||
"(method=%s status=%s); dropping per RFC 9110.",
|
||||
self.req.method, self.status_code,
|
||||
)
|
||||
self._omits_body_warned = True
|
||||
return
|
||||
arglen = len(arg)
|
||||
tosend = arglen
|
||||
if self.response_length is not None:
|
||||
@ -448,6 +480,17 @@ class Response:
|
||||
if self.cfg.is_ssl or not self.can_sendfile():
|
||||
return False
|
||||
|
||||
if self._omits_body:
|
||||
self.send_headers()
|
||||
if not self._omits_body_warned:
|
||||
log.warning(
|
||||
"WSGI app sent body bytes on a no-body response "
|
||||
"(method=%s status=%s); dropping per RFC 9110.",
|
||||
self.req.method, self.status_code,
|
||||
)
|
||||
self._omits_body_warned = True
|
||||
return True
|
||||
|
||||
if not util.has_fileno(respiter.filelike):
|
||||
return False
|
||||
|
||||
|
||||
@ -371,3 +371,69 @@ def test_file_wrapper_iterable():
|
||||
wrapper2 = FileWrapper(filelike2, blksize=2)
|
||||
chunks = list(wrapper2)
|
||||
assert chunks == [b"ab", b"c"]
|
||||
|
||||
|
||||
def _make_response(method="GET", version=(1, 1)):
|
||||
sock = mock.MagicMock()
|
||||
req = mock.MagicMock()
|
||||
req.method = method
|
||||
req.version = version
|
||||
req.should_close.return_value = False
|
||||
cfg = mock.MagicMock()
|
||||
cfg.is_ssl = False
|
||||
cfg.sendfile = False
|
||||
return Response(req, sock, cfg), sock
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,method,expect_cl", [
|
||||
("204 No Content", "GET", False),
|
||||
("100 Continue", "GET", False),
|
||||
("199 Custom", "GET", False),
|
||||
("304 Not Modified", "GET", True),
|
||||
("200 OK", "HEAD", True),
|
||||
])
|
||||
def test_no_body_response_strips_framing(status, method, expect_cl):
|
||||
"""1xx/204 strip Content-Length; HEAD/304 keep app-supplied Content-Length."""
|
||||
resp, _ = _make_response(method=method)
|
||||
body_len = 12
|
||||
resp.start_response(status, [
|
||||
("Content-Type", "text/plain"),
|
||||
("Content-Length", str(body_len)),
|
||||
])
|
||||
header_keys = [k.lower() for k, _ in resp.headers]
|
||||
if expect_cl:
|
||||
assert "content-length" in header_keys
|
||||
assert resp.response_length == body_len
|
||||
else:
|
||||
assert "content-length" not in header_keys
|
||||
assert resp.response_length is None
|
||||
assert resp.chunked is False
|
||||
assert resp._omits_body is True
|
||||
|
||||
|
||||
def test_no_body_response_drops_body_and_warns(caplog):
|
||||
resp, sock = _make_response(method="GET")
|
||||
resp.start_response("204 No Content", [
|
||||
("Content-Type", "text/plain"),
|
||||
("Content-Length", "5"),
|
||||
])
|
||||
with caplog.at_level("WARNING", logger="gunicorn.http.wsgi"):
|
||||
resp.write(b"hello")
|
||||
resp.write(b"again")
|
||||
assert resp.sent == 0
|
||||
assert sum(
|
||||
1 for r in caplog.records
|
||||
if "no-body response" in r.getMessage()
|
||||
) == 1
|
||||
|
||||
|
||||
def test_normal_response_unaffected():
|
||||
resp, _ = _make_response(method="GET")
|
||||
resp.start_response("200 OK", [
|
||||
("Content-Type", "text/plain"),
|
||||
("Content-Length", "5"),
|
||||
])
|
||||
assert resp._omits_body is False
|
||||
assert resp.response_length == 5
|
||||
resp.write(b"hello")
|
||||
assert resp.sent == 5
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user