From 8864ac407bba8607950025e0979c4c69bc7abc7b Mon Sep 17 00:00:00 2001 From: Illia Volochii Date: Wed, 7 Jan 2026 18:07:30 +0200 Subject: [PATCH] Merge commit from fork * Stop decoding response content during redirects needlessly * Rename the new query parameter * Add a changelog entry CVE: CVE-2026-21441 Upstream-Status: Backport [https://github.com/urllib3/urllib3/commit/8864ac407bba8607950025e0979c4c69bc7abc7b] Signed-off-by: Peter Marko --- CHANGES.rst | 13 +++++++++++++ dummyserver/app.py | 8 +++++++- src/urllib3/response.py | 6 +++++- test/with_dummyserver/test_connectionpool.py | 19 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2de9f016..4c0b9cea 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,16 @@ +(TBD) +================== + +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 `__) + +TODO: add other entries. + + 2.5.0 (2025-06-18) ================== diff --git a/dummyserver/app.py b/dummyserver/app.py index 0eeb93f7..5b82e932 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 f6266f1a..ff6d1f49 100644 --- a/src/urllib3/response.py +++ b/src/urllib3/response.py @@ -687,7 +687,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 ce165e24..8d6107ae 100644 --- a/test/with_dummyserver/test_connectionpool.py +++ b/test/with_dummyserver/test_connectionpool.py @@ -508,6 +508,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(