mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-02 18:51:31 +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.sent = 0
|
||||||
self.upgrade = False
|
self.upgrade = False
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
|
self._omits_body = False
|
||||||
|
self._omits_body_warned = False
|
||||||
|
|
||||||
def force_close(self):
|
def force_close(self):
|
||||||
self.must_close = True
|
self.must_close = True
|
||||||
@ -336,9 +338,34 @@ class Response:
|
|||||||
self.status_code = None
|
self.status_code = None
|
||||||
|
|
||||||
self.process_headers(headers)
|
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()
|
self.chunked = self.is_chunked()
|
||||||
return self.write
|
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):
|
def process_headers(self, headers):
|
||||||
for name, value in headers:
|
for name, value in headers:
|
||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
@ -379,12 +406,8 @@ class Response:
|
|||||||
return False
|
return False
|
||||||
elif self.req.version <= (1, 0):
|
elif self.req.version <= (1, 0):
|
||||||
return False
|
return False
|
||||||
elif self.req.method == 'HEAD':
|
elif self._omits_body:
|
||||||
# Responses to a HEAD request MUST NOT contain a response body.
|
# No body permitted (HEAD or 1xx/204/304), so no chunked framing.
|
||||||
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.
|
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -422,6 +445,15 @@ class Response:
|
|||||||
self.send_headers()
|
self.send_headers()
|
||||||
if not isinstance(arg, bytes):
|
if not isinstance(arg, bytes):
|
||||||
raise TypeError('%r is not a byte' % arg)
|
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)
|
arglen = len(arg)
|
||||||
tosend = arglen
|
tosend = arglen
|
||||||
if self.response_length is not None:
|
if self.response_length is not None:
|
||||||
@ -448,6 +480,17 @@ class Response:
|
|||||||
if self.cfg.is_ssl or not self.can_sendfile():
|
if self.cfg.is_ssl or not self.can_sendfile():
|
||||||
return False
|
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):
|
if not util.has_fileno(respiter.filelike):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@ -371,3 +371,69 @@ def test_file_wrapper_iterable():
|
|||||||
wrapper2 = FileWrapper(filelike2, blksize=2)
|
wrapper2 = FileWrapper(filelike2, blksize=2)
|
||||||
chunks = list(wrapper2)
|
chunks = list(wrapper2)
|
||||||
assert chunks == [b"ab", b"c"]
|
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