From b043dfbce0831191cc926cdf58af9e3b34eb50d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Mon, 12 Jan 2026 12:35:16 +0100 Subject: [PATCH] Security fix for CVE-2026-21441 Related: RHEL-139410 --- CVE-2026-21441.patch | 219 +++++++++++++++++++++++++++++++++++++++++++ python-urllib3.spec | 3 + 2 files changed, 222 insertions(+) create mode 100644 CVE-2026-21441.patch diff --git a/CVE-2026-21441.patch b/CVE-2026-21441.patch new file mode 100644 index 0000000..b6f101a --- /dev/null +++ b/CVE-2026-21441.patch @@ -0,0 +1,219 @@ +From 5474aa5a56209bf0378201d8305584a0f51f91c7 Mon Sep 17 00:00:00 2001 +From: Ousret +Date: Thu, 17 Nov 2022 01:40:19 +0100 +Subject: [PATCH 1/2] Prevent issue in HTTPResponse().read() when + decoded_content is True and then False Provided it has initialized eligible + decoder(decompressor) and did decode once + +(cherry picked from commit cefd1dbba6a20ea4f017e6e472f9ada3a8a743e0) +--- + changelog/2800.bugfix.rst | 1 + + src/urllib3/response.py | 13 +++++++++++++ + test/test_response.py | 35 +++++++++++++++++++++++++++++++++++ + 3 files changed, 49 insertions(+) + create mode 100644 changelog/2800.bugfix.rst + +diff --git a/changelog/2800.bugfix.rst b/changelog/2800.bugfix.rst +new file mode 100644 +index 00000000..9dcf1eec +--- /dev/null ++++ b/changelog/2800.bugfix.rst +@@ -0,0 +1 @@ ++Prevented issue in HTTPResponse().read() when decoded_content is True and then False. +\ No newline at end of file +diff --git a/src/urllib3/response.py b/src/urllib3/response.py +index ba366eef..6c8823be 100644 +--- a/src/urllib3/response.py ++++ b/src/urllib3/response.py +@@ -331,6 +331,7 @@ class HTTPResponse(io.IOBase): + self.reason = reason + self.strict = strict + self.decode_content = decode_content ++ self._has_decoded_content = False + self.retries = retries + self.enforce_content_length = enforce_content_length + +@@ -481,12 +482,19 @@ class HTTPResponse(io.IOBase): + """ + Decode the data passed in and potentially flush the decoder. + """ ++ if not decode_content and self._has_decoded_content: ++ raise RuntimeError( ++ "Calling read(decode_content=False) is not supported after " ++ "read(decode_content=True) was called." ++ ) ++ + if max_length is None or flush_decoder: + max_length = -1 + + try: + if decode_content and self._decoder: + data = self._decoder.decompress(data, max_length=max_length) ++ self._has_decoded_content = True + except (IOError, zlib.error) as e: + content_encoding = self.headers.get('content-encoding', '').lower() + raise DecodeError( +@@ -670,6 +678,11 @@ class HTTPResponse(io.IOBase): + else: + # do not waste memory on buffer when not decoding + if not decode_content: ++ if self._has_decoded_content: ++ raise RuntimeError( ++ "Calling read(decode_content=False) is not supported after " ++ "read(decode_content=True) was called." ++ ) + return data + + decoded_data = self._decode( +diff --git a/test/test_response.py b/test/test_response.py +index bd083b7f..4b04e409 100644 +--- a/test/test_response.py ++++ b/test/test_response.py +@@ -560,6 +560,41 @@ class TestResponse(object): + while not br.closed: + br.read(5) + ++ def test_read_with_illegal_mix_decode_toggle(self): ++ compress = zlib.compressobj(6, zlib.DEFLATED, -zlib.MAX_WBITS) ++ data = compress.compress(b"foo") ++ data += compress.flush() ++ ++ fp = BytesIO(data) ++ ++ resp = HTTPResponse( ++ fp, headers={"content-encoding": "deflate"}, preload_content=False ++ ) ++ ++ assert resp.read(1) == b"f" ++ ++ with pytest.raises( ++ RuntimeError, ++ match=( ++ r"Calling read\(decode_content=False\) is not supported after " ++ r"read\(decode_content=True\) was called" ++ ), ++ ): ++ resp.read(1, decode_content=False) ++ ++ def test_read_with_mix_decode_toggle(self): ++ compress = zlib.compressobj(6, zlib.DEFLATED, -zlib.MAX_WBITS) ++ data = compress.compress(b"foo") ++ data += compress.flush() ++ ++ fp = BytesIO(data) ++ ++ resp = HTTPResponse( ++ fp, headers={"content-encoding": "deflate"}, preload_content=False ++ ) ++ resp.read(1, decode_content=False) ++ assert resp.read(1, decode_content=True) == b"o" ++ + def test_streaming(self): + fp = BytesIO(b'foo') + resp = HTTPResponse(fp, preload_content=False) +-- +2.52.0 + + +From 018e71279b1b9ba9319943dbe47762d2dad631d0 Mon Sep 17 00:00:00 2001 +From: Illia Volochii +Date: Wed, 7 Jan 2026 18:07:30 +0200 +Subject: [PATCH 2/2] Security fix for CVE-2026-21441 + +* Stop decoding response content during redirects needlessly +* Rename the new query parameter +* Add a changelog entry + +(cherry picked from commit 8864ac407bba8607950025e0979c4c69bc7abc7b) +--- + CHANGES.rst | 3 +++ + dummyserver/handlers.py | 8 +++++++- + src/urllib3/connectionpool.py | 6 +++++- + test/with_dummyserver/test_connectionpool.py | 19 +++++++++++++++++++ + 4 files changed, 34 insertions(+), 2 deletions(-) + +diff --git a/CHANGES.rst b/CHANGES.rst +index cf315aaf..5673a58e 100644 +--- a/CHANGES.rst ++++ b/CHANGES.rst +@@ -8,6 +8,9 @@ Backports + compressed HTTP content ("decompression bombs") leading to excessive resource + consumption even when a small amount of data was requested. Reading small + chunks of compressed data is safer and much more efficient now. ++- 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 `__) + + + 1.24.2 (2019-04-17) +diff --git a/dummyserver/handlers.py b/dummyserver/handlers.py +index f570d881..c0dd4e4a 100644 +--- a/dummyserver/handlers.py ++++ b/dummyserver/handlers.py +@@ -170,9 +170,15 @@ class TestingApp(RequestHandler): + status = request.params.get('status', '303 See Other') + if len(status) == 3: + status = '%s Redirect' % status.decode('latin-1') ++ compressed = request.params.get('compressed') == b'true' + + headers = [('Location', target)] +- return Response(status=status, headers=headers) ++ if compressed: ++ headers.append(('Content-Encoding', 'gzip')) ++ data = gzip.compress(b'foo') ++ else: ++ data = b'' ++ return Response(data, status=status, headers=headers) + + def not_found(self, request): + return Response('Not found', status='404 Not Found') +diff --git a/src/urllib3/connectionpool.py b/src/urllib3/connectionpool.py +index ad6303c1..5f66cd25 100644 +--- a/src/urllib3/connectionpool.py ++++ b/src/urllib3/connectionpool.py +@@ -671,7 +671,11 @@ class HTTPConnectionPool(ConnectionPool, RequestMethods): + try: + # discard any remaining response body, the connection will be + # released back to the pool once the entire response is read +- response.read() ++ response.read( ++ # Do not spend resources decoding the content unless ++ # decoding has already been initiated. ++ decode_content=response._has_decoded_content, ++ ) + except (TimeoutError, HTTPException, SocketError, ProtocolError, + BaseSSLError, SSLError) as e: + pass +diff --git a/test/with_dummyserver/test_connectionpool.py b/test/with_dummyserver/test_connectionpool.py +index 5faa0638..a2673e05 100644 +--- a/test/with_dummyserver/test_connectionpool.py ++++ b/test/with_dummyserver/test_connectionpool.py +@@ -413,6 +413,25 @@ class TestConnectionPool(HTTPDummyServerTestCase): + self.assertEqual(r.status, 200) + self.assertEqual(r.data, b'Dummy server!') + ++ @mock.patch("urllib3.response.GzipDecoder.decompress") ++ def test_no_decoding_with_redirect_when_preload_disabled( ++ self, gzip_decompress ++ ): ++ """ ++ 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_bad_connect(self): + pool = HTTPConnectionPool('badhost.invalid', self.port) + try: +-- +2.52.0 + diff --git a/python-urllib3.spec b/python-urllib3.spec index dd2a476..cc9ffdb 100644 --- a/python-urllib3.spec +++ b/python-urllib3.spec @@ -57,6 +57,7 @@ Patch6: CVE-2024-37891.patch Patch7: CVE-2025-66471.patch Patch8: CVE-2025-66418.patch +Patch9: CVE-2026-21441.patch %description Python HTTP module with connection pooling and file POST abilities. @@ -92,6 +93,7 @@ Python3 HTTP module with connection pooling and file POST abilities. %patch6 -p1 %patch7 -p1 %patch8 -p1 +%patch9 -p1 # Make sure that the RECENT_DATE value doesn't get too far behind what the current date is. # RECENT_DATE must not be older that 2 years from the build time, or else test_recent_date @@ -170,6 +172,7 @@ popd * Wed Dec 17 2025 Miro Hrončok - 1.24.2-9 - Security fix for CVE-2025-66471 - Security fix for CVE-2025-66418 +- Security fix for CVE-2026-21441 Resolves: RHEL-139410 * Mon Jul 01 2024 Lumír Balhar - 1.24.2-8