From 686d2bdd4affd3c86e605f54a72afe53c920f72f Mon Sep 17 00:00:00 2001 From: Illia Volochii Date: Wed, 7 Jan 2026 18:07:30 +0200 Subject: [PATCH] Backport fix CVE-2026-21441 python urllib3 Original commit: 8864ac407bba8607950025e0979c4c69bc7abc7b Original-author: Illia Volochii Bugfixes -------- - Fixed a high-severity security issue where decompression-bomb safeguards of the streaming API were bypassed when HTTP redirects were followed. (`GHSA-38jv-5279-wg99 `__) * Stop decoding response content during redirects needlessly * Rename the new query parameter * Add a changelog entry Fixes CVE-2026-21441 CVE: CVE-2026-21441 Upstream-Status: Backport [https://github.com/urllib3/urllib3/commit/8864ac407bba8607950025e0979c4c69bc7abc7b] Signed-off-by: Adarsh Jagadish Kamini --- dummyserver/app.py | 8 +++++++- src/urllib3/response.py | 6 +++++- test/with_dummyserver/test_connectionpool.py | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/dummyserver/app.py b/dummyserver/app.py index 9fc9d1b7..c4978152 100644 --- a/dummyserver/app.py +++ b/dummyserver/app.py @@ -233,10 +233,16 @@ async def redirect() -> ResponseReturnValue: values = await request.values target = values.get("target", "/") status = values.get("status", "303 See Other") + compressed = values.get("compressed") == "true" status_code = status.split(" ")[0] headers = [("Location", target)] - return await make_response("", status_code, headers) + if compressed: + headers.append(("Content-Encoding", "gzip")) + data = gzip.compress(b"foo") + else: + data = b"" + return await make_response(data, status_code, headers) @hypercorn_app.route("/redirect_after") diff --git a/src/urllib3/response.py b/src/urllib3/response.py index a0273d65..909da62b 100644 --- a/src/urllib3/response.py +++ b/src/urllib3/response.py @@ -646,7 +646,11 @@ class HTTPResponse(BaseHTTPResponse): Unread data in the HTTPResponse connection blocks the connection from being released back to the pool. """ try: - self.read() + self.read( + # Do not spend resources decoding the content unless + # decoding has already been initiated. + decode_content=self._has_decoded_content, + ) except (HTTPError, OSError, BaseSSLError, HTTPException): pass diff --git a/test/with_dummyserver/test_connectionpool.py b/test/with_dummyserver/test_connectionpool.py index 4fbe6a4f..ebcdf9bf 100644 --- a/test/with_dummyserver/test_connectionpool.py +++ b/test/with_dummyserver/test_connectionpool.py @@ -480,6 +480,25 @@ class TestConnectionPool(HypercornDummyServerTestCase): assert r.status == 200 assert r.data == b"Dummy server!" + @mock.patch("urllib3.response.GzipDecoder.decompress") + def test_no_decoding_with_redirect_when_preload_disabled( + self, gzip_decompress: mock.MagicMock + ) -> None: + """ + Test that urllib3 does not attempt to decode a gzipped redirect + response when `preload_content` is set to `False`. + """ + with HTTPConnectionPool(self.host, self.port) as pool: + # Three requests are expected: two redirects and one final / 200 OK. + response = pool.request( + "GET", + "/redirect", + fields={"target": "/redirect?compressed=true", "compressed": "true"}, + preload_content=False, + ) + assert response.status == 200 + gzip_decompress.assert_not_called() + def test_303_redirect_makes_request_lose_body(self) -> None: with HTTPConnectionPool(self.host, self.port) as pool: response = pool.request( -- 2.44.0