Skip to content

Commit f3f5234

Browse files
miss-islingtonencukourasmusfaber
committed
gh-156002: Keep reading through monkey-patched zipfile decompressors (GH-157180) (GH-157557)
(cherry picked from commit f507e69) Co-authored-by: Petr Viktorin <encukou@gmail.com> Co-authored-by: rasmusfaber <rfaber@gmail.com>
1 parent d53a41e commit f3f5234

3 files changed

Lines changed: 81 additions & 11 deletions

File tree

‎Lib/test/test_zipfile/test_core.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2655,6 +2655,74 @@ class LzmaBoundedDecompressTests(AbstractBoundedDecompressTests,
26552655
compression = zipfile.ZIP_LZMA
26562656

26572657

2658+
2659+
class MonkeypatchedDecompressorTests(unittest.TestCase):
2660+
# Some third-party projects monkey-patch _get_decompressor() to add
2661+
# additional compression schemes. This can break at any time as the
2662+
# internal compressor objects change.
2663+
# To protect users, we try to keep this case working.
2664+
# See also: GH-156002 and GH-113767.
2665+
COMPRESSION = 99
2666+
2667+
class Compressor:
2668+
"""Compressor with only the original BZ2Compressor API"""
2669+
def compress(self, data):
2670+
return data.swapcase()
2671+
2672+
def flush(self):
2673+
return b''
2674+
2675+
class Decompressor:
2676+
"""Decompressor with only the 3.3+ BZ2Decompressor API"""
2677+
eof = False
2678+
2679+
def decompress(self, data):
2680+
return data.swapcase()
2681+
2682+
def setUp(self):
2683+
orig_check_compression = zipfile._check_compression
2684+
orig_get_compressor = zipfile._get_compressor
2685+
orig_get_decompressor = zipfile._get_decompressor
2686+
2687+
def check_compression(compression):
2688+
if compression != self.COMPRESSION:
2689+
orig_check_compression(compression)
2690+
2691+
def get_compressor(compress_type, compresslevel=None):
2692+
if compress_type == self.COMPRESSION:
2693+
return self.Compressor()
2694+
return orig_get_compressor(compress_type, compresslevel)
2695+
2696+
def get_decompressor(compress_type):
2697+
if compress_type == self.COMPRESSION:
2698+
return self.Decompressor()
2699+
return orig_get_decompressor(compress_type)
2700+
2701+
self.enterContext(mock.patch.object(
2702+
zipfile, '_check_compression', check_compression))
2703+
self.enterContext(mock.patch.object(
2704+
zipfile, '_get_compressor', get_compressor))
2705+
self.enterContext(mock.patch.object(
2706+
zipfile, '_get_decompressor', get_decompressor))
2707+
2708+
def test_roundtrip_monkeypatched_decompressor(self):
2709+
data = bytes(range(256)) * 8
2710+
buf = io.BytesIO()
2711+
with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf:
2712+
zf.writestr("member", data)
2713+
self.assertIn(data.swapcase(), buf.getvalue())
2714+
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
2715+
self.assertEqual(zf.read("member"), data)
2716+
with zf.open("member") as f:
2717+
self.assertEqual(f.read(100), data[:100])
2718+
self.assertEqual(f.read1(100), data[100:200])
2719+
f.seek(-100, os.SEEK_END)
2720+
self.assertEqual(f.read(), data[-100:])
2721+
# Rewinding past the read buffer re-creates the decompressor.
2722+
f.seek(0)
2723+
self.assertEqual(f.read(), data)
2724+
2725+
26582726
class AbstractBadCrcTests:
26592727
def test_testzip_with_bad_crc(self):
26602728
"""Tests that files with bad CRCs return their name from testzip."""

‎Lib/zipfile/__init__.py‎

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,7 @@ def __init__(self):
727727
self.eof = False
728728

729729
@property
730-
def _needs_input(self):
730+
def needs_input(self):
731731
# While the LZMA properties header is still being buffered, more input
732732
# is required; afterwards defer to the wrapped decompressor so a bounded
733733
# decompress() call can be drained across reads.
@@ -811,13 +811,6 @@ def _get_compressor(compress_type, compresslevel=None):
811811
return None
812812

813813

814-
def _decompressor_needs_input(decompressor):
815-
# bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA
816-
# wrapper keeps it private (_needs_input) to avoid adding public API.
817-
needs_input = getattr(decompressor, "needs_input", None)
818-
return decompressor._needs_input if needs_input is None else needs_input
819-
820-
821814
def _get_decompressor(compress_type):
822815
_check_compression(compress_type)
823816
if compress_type == ZIP_STORED:
@@ -1123,7 +1116,7 @@ def _read1(self, n):
11231116
else:
11241117
# bzip2/lzma/zstd: a bounded decompress() call may leave input
11251118
# buffered inside the decompressor; drain that before reading more.
1126-
if _decompressor_needs_input(self._decompressor):
1119+
if getattr(self._decompressor, "needs_input", True):
11271120
data = self._read2(n)
11281121
else:
11291122
data = b''
@@ -1142,10 +1135,14 @@ def _read1(self, n):
11421135
# Bound the output of a single decompress() call (mirroring the
11431136
# DEFLATE path above) so that a small compressed member cannot
11441137
# expand into one unbounded read.
1145-
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1138+
try:
1139+
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1140+
except TypeError:
1141+
# See MonkeypatchedDecompressorTests in test_core.py
1142+
data = self._decompressor.decompress(data)
11461143
self._eof = (self._decompressor.eof or
11471144
self._compress_left <= 0 and
1148-
_decompressor_needs_input(self._decompressor))
1145+
getattr(self._decompressor, "needs_input", True))
11491146

11501147
data = data[:self._left]
11511148
self._left -= len(data)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
:mod:`zipfile` again reads members through a third-party decompressor
2+
installed by monkey-patching the private ``_get_decompressor()`` to return an
3+
object that only implements old BZ2Decompressor API from Python 3.3.
4+
Note that decompressors without ``needs_input`` and two-argument
5+
``decompress()`` are vulnerable to :cve:`2026-15310`.

0 commit comments

Comments
 (0)