diff --git a/gunicorn/http/wsgi.py b/gunicorn/http/wsgi.py index 6ad839ca..629200ff 100644 --- a/gunicorn/http/wsgi.py +++ b/gunicorn/http/wsgi.py @@ -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 diff --git a/tests/test_http.py b/tests/test_http.py index afc1843b..477a810e 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -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