https://github.com/python/cpython/commit/9d167992b59cf5e23c66b9ed742b13f5925f7d70
commit: 9d167992b59cf5e23c66b9ed742b13f5925f7d70
branch: 3.14
author: Miss Islington (bot) <[email protected]>
committer: encukou <[email protected]>
date: 2026-09-16T15:52:44+02:00
summary:

[3.14] gh-156002: Keep reading through monkey-patched zipfile decompressors 
(GH-157180) (GH-157557)

(cherry picked from commit f507e6946a3194e83e1d7b8ee6e14567175e46de)

Co-authored-by: Petr Viktorin <[email protected]>
Co-authored-by: rasmusfaber <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst
M Lib/test/test_zipfile/test_core.py
M Lib/zipfile/__init__.py

diff --git a/Lib/test/test_zipfile/test_core.py 
b/Lib/test/test_zipfile/test_core.py
index 4da8fadddc0700c..e7e3de4217874df 100644
--- a/Lib/test/test_zipfile/test_core.py
+++ b/Lib/test/test_zipfile/test_core.py
@@ -2765,6 +2765,73 @@ class 
ZstdBoundedDecompressTests(AbstractBoundedDecompressTests,
     compression = zipfile.ZIP_ZSTANDARD
 
 
+class MonkeypatchedDecompressorTests(unittest.TestCase):
+    # Some third-party projects monkey-patch _get_decompressor() to add
+    # additional compression schemes. This can break at any time as the
+    # internal compressor objects change.
+    # To protect users, we try to keep this case working.
+    # See also: GH-156002 and GH-113767.
+    COMPRESSION = 99
+
+    class Compressor:
+        """Compressor with only the original BZ2Compressor API"""
+        def compress(self, data):
+            return data.swapcase()
+
+        def flush(self):
+            return b''
+
+    class Decompressor:
+        """Decompressor with only the 3.3+ BZ2Decompressor API"""
+        eof = False
+
+        def decompress(self, data):
+            return data.swapcase()
+
+    def setUp(self):
+        orig_check_compression = zipfile._check_compression
+        orig_get_compressor = zipfile._get_compressor
+        orig_get_decompressor = zipfile._get_decompressor
+
+        def check_compression(compression):
+            if compression != self.COMPRESSION:
+                orig_check_compression(compression)
+
+        def get_compressor(compress_type, compresslevel=None):
+            if compress_type == self.COMPRESSION:
+                return self.Compressor()
+            return orig_get_compressor(compress_type, compresslevel)
+
+        def get_decompressor(compress_type):
+            if compress_type == self.COMPRESSION:
+                return self.Decompressor()
+            return orig_get_decompressor(compress_type)
+
+        self.enterContext(mock.patch.object(
+            zipfile, '_check_compression', check_compression))
+        self.enterContext(mock.patch.object(
+            zipfile, '_get_compressor', get_compressor))
+        self.enterContext(mock.patch.object(
+            zipfile, '_get_decompressor', get_decompressor))
+
+    def test_roundtrip_monkeypatched_decompressor(self):
+        data = bytes(range(256)) * 8
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf:
+            zf.writestr("member", data)
+        self.assertIn(data.swapcase(), buf.getvalue())
+        with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
+            self.assertEqual(zf.read("member"), data)
+            with zf.open("member") as f:
+                self.assertEqual(f.read(100), data[:100])
+                self.assertEqual(f.read1(100), data[100:200])
+                f.seek(-100, os.SEEK_END)
+                self.assertEqual(f.read(), data[-100:])
+                # Rewinding past the read buffer re-creates the decompressor.
+                f.seek(0)
+                self.assertEqual(f.read(), data)
+
+
 class AbstractBadCrcTests:
     def test_testzip_with_bad_crc(self):
         """Tests that files with bad CRCs return their name from testzip."""
diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py
index 15cd7643a522b80..43bf2200352427c 100644
--- a/Lib/zipfile/__init__.py
+++ b/Lib/zipfile/__init__.py
@@ -787,7 +787,7 @@ def __init__(self):
         self.eof = False
 
     @property
-    def _needs_input(self):
+    def needs_input(self):
         # While the LZMA properties header is still being buffered, more input
         # is required; afterwards defer to the wrapped decompressor so a 
bounded
         # decompress() call can be drained across reads.
@@ -878,13 +878,6 @@ def _get_compressor(compress_type, compresslevel=None):
         return None
 
 
-def _decompressor_needs_input(decompressor):
-    # bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA
-    # wrapper keeps it private (_needs_input) to avoid adding public API.
-    needs_input = getattr(decompressor, "needs_input", None)
-    return decompressor._needs_input if needs_input is None else needs_input
-
-
 def _get_decompressor(compress_type):
     _check_compression(compress_type)
     if compress_type == ZIP_STORED:
@@ -1192,7 +1185,7 @@ def _read1(self, n):
         else:
             # bzip2/lzma/zstd: a bounded decompress() call may leave input
             # buffered inside the decompressor; drain that before reading more.
-            if _decompressor_needs_input(self._decompressor):
+            if getattr(self._decompressor, "needs_input", True):
                 data = self._read2(n)
             else:
                 data = b''
@@ -1211,10 +1204,14 @@ def _read1(self, n):
             # Bound the output of a single decompress() call (mirroring the
             # DEFLATE path above) so that a small compressed member cannot
             # expand into one unbounded read.
-            data = self._decompressor.decompress(data, max(n, 
self.MIN_READ_SIZE))
+            try:
+                data = self._decompressor.decompress(data, max(n, 
self.MIN_READ_SIZE))
+            except TypeError:
+                # See MonkeypatchedDecompressorTests in test_core.py
+                data = self._decompressor.decompress(data)
             self._eof = (self._decompressor.eof or
                          self._compress_left <= 0 and
-                         _decompressor_needs_input(self._decompressor))
+                         getattr(self._decompressor, "needs_input", True))
 
         data = data[:self._left]
         self._left -= len(data)
diff --git 
a/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst 
b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst
new file mode 100644
index 000000000000000..a21386803cca0f4
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-09-08-13-06-29.gh-issue-156002.vmOC8T.rst
@@ -0,0 +1,5 @@
+:mod:`zipfile` again reads members through a third-party decompressor
+installed by monkey-patching the private ``_get_decompressor()`` to return an
+object that only implements old BZ2Decompressor API from Python 3.3.
+Note that decompressors without ``needs_input`` and two-argument
+``decompress()`` are vulnerable to :cve:`2026-15310`.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to