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