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:
Benoit Chesneau 2026-05-05 11:30:24 +02:00
parent 5d819cf360
commit 9bc5891b4b
2 changed files with 115 additions and 6 deletions

View File

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

View File

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