Skip to content

Commit f507e69

Browse files
encukourasmusfaber
andauthored
[3.15] gh-156002: Keep reading through monkey-patched zipfile decompressors (GH-157180) (#157268)
Co-authored-by: rasmusfaber <rfaber@gmail.com>
1 parent 4a42aed commit f507e69

3 files changed

Lines changed: 80 additions & 11 deletions

File tree

Lib/test/test_zipfile/test_core.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2761,6 +2761,73 @@ class ZstdBoundedDecompressTests(AbstractBoundedDecompressTests,
27612761
compression = zipfile.ZIP_ZSTANDARD
27622762

27632763

2764+
class MonkeypatchedDecompressorTests(unittest.TestCase):
2765+
# Some third-party projects monkey-patch _get_decompressor() to add
2766+
# additional compression schemes. This can break at any time as the
2767+
# internal compressor objects change.
2768+
# To protect users, we try to keep this case working.
2769+
# See also: GH-156002 and GH-113767.
2770+
COMPRESSION = 99
2771+
2772+
class Compressor:
2773+
"""Compressor with only the original BZ2Compressor API"""
2774+
def compress(self, data):
2775+
return data.swapcase()
2776+
2777+
def flush(self):
2778+
return b''
2779+
2780+
class Decompressor:
2781+
"""Decompressor with only the 3.3+ BZ2Decompressor API"""
2782+
eof = False
2783+
2784+
def decompress(self, data):
2785+
return data.swapcase()
2786+
2787+
def setUp(self):
2788+
orig_check_compression = zipfile._check_compression
2789+
orig_get_compressor = zipfile._get_compressor
2790+
orig_get_decompressor = zipfile._get_decompressor
2791+
2792+
def check_compression(compression):
2793+
if compression != self.COMPRESSION:
2794+
orig_check_compression(compression)
2795+
2796+
def get_compressor(compress_type, compresslevel=None):
2797+
if compress_type == self.COMPRESSION:
2798+
return self.Compressor()
2799+
return orig_get_compressor(compress_type, compresslevel)
2800+
2801+
def get_decompressor(compress_type):
2802+
if compress_type == self.COMPRESSION:
2803+
return self.Decompressor()
2804+
return orig_get_decompressor(compress_type)
2805+
2806+
self.enterContext(mock.patch.object(
2807+
zipfile, '_check_compression', check_compression))
2808+
self.enterContext(mock.patch.object(
2809+
zipfile, '_get_compressor', get_compressor))
2810+
self.enterContext(mock.patch.object(
2811+
zipfile, '_get_decompressor', get_decompressor))
2812+
2813+
def test_roundtrip_monkeypatched_decompressor(self):
2814+
data = bytes(range(256)) * 8
2815+
buf = io.BytesIO()
2816+
with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf:
2817+
zf.writestr("member", data)
2818+
self.assertIn(data.swapcase(), buf.getvalue())
2819+
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
2820+
self.assertEqual(zf.read("member"), data)
2821+
with zf.open("member") as f:
2822+
self.assertEqual(f.read(100), data[:100])
2823+
self.assertEqual(f.read1(100), data[100:200])
2824+
f.seek(-100, os.SEEK_END)
2825+
self.assertEqual(f.read(), data[-100:])
2826+
# Rewinding past the read buffer re-creates the decompressor.
2827+
f.seek(0)
2828+
self.assertEqual(f.read(), data)
2829+
2830+
27642831
class AbstractBadCrcTests:
27652832
def test_testzip_with_bad_crc(self):
27662833
"""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
@@ -787,7 +787,7 @@ def __init__(self):
787787
self.eof = False
788788

789789
@property
790-
def _needs_input(self):
790+
def needs_input(self):
791791
# While the LZMA properties header is still being buffered, more input
792792
# is required; afterwards defer to the wrapped decompressor so a bounded
793793
# decompress() call can be drained across reads.
@@ -878,13 +878,6 @@ def _get_compressor(compress_type, compresslevel=None):
878878
return None
879879

880880

881-
def _decompressor_needs_input(decompressor):
882-
# bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA
883-
# wrapper keeps it private (_needs_input) to avoid adding public API.
884-
needs_input = getattr(decompressor, "needs_input", None)
885-
return decompressor._needs_input if needs_input is None else needs_input
886-
887-
888881
def _get_decompressor(compress_type):
889882
_check_compression(compress_type)
890883
if compress_type == ZIP_STORED:
@@ -1192,7 +1185,7 @@ def _read1(self, n):
11921185
else:
11931186
# bzip2/lzma/zstd: a bounded decompress() call may leave input
11941187
# buffered inside the decompressor; drain that before reading more.
1195-
if _decompressor_needs_input(self._decompressor):
1188+
if getattr(self._decompressor, "needs_input", True):
11961189
data = self._read2(n)
11971190
else:
11981191
data = b''
@@ -1211,10 +1204,14 @@ def _read1(self, n):
12111204
# Bound the output of a single decompress() call (mirroring the
12121205
# DEFLATE path above) so that a small compressed member cannot
12131206
# expand into one unbounded read.
1214-
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1207+
try:
1208+
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1209+
except TypeError:
1210+
# See MonkeypatchedDecompressorTests in test_core.py
1211+
data = self._decompressor.decompress(data)
12151212
self._eof = (self._decompressor.eof or
12161213
self._compress_left <= 0 and
1217-
_decompressor_needs_input(self._decompressor))
1214+
getattr(self._decompressor, "needs_input", True))
12181215

12191216
data = data[:self._left]
12201217
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)