Skip to content

Commit 9f8e998

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 0da3ad5 commit 9f8e998

3 files changed

Lines changed: 81 additions & 11 deletions

File tree

‎Lib/test/test_zipfile.py‎

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

24562456

2457+
2458+
class MonkeypatchedDecompressorTests(unittest.TestCase):
2459+
# Some third-party projects monkey-patch _get_decompressor() to add
2460+
# additional compression schemes. This can break at any time as the
2461+
# internal compressor objects change.
2462+
# To protect users, we try to keep this case working.
2463+
# See also: GH-156002 and GH-113767.
2464+
COMPRESSION = 99
2465+
2466+
class Compressor:
2467+
"""Compressor with only the original BZ2Compressor API"""
2468+
def compress(self, data):
2469+
return data.swapcase()
2470+
2471+
def flush(self):
2472+
return b''
2473+
2474+
class Decompressor:
2475+
"""Decompressor with only the 3.3+ BZ2Decompressor API"""
2476+
eof = False
2477+
2478+
def decompress(self, data):
2479+
return data.swapcase()
2480+
2481+
def setUp(self):
2482+
orig_check_compression = zipfile._check_compression
2483+
orig_get_compressor = zipfile._get_compressor
2484+
orig_get_decompressor = zipfile._get_decompressor
2485+
2486+
def check_compression(compression):
2487+
if compression != self.COMPRESSION:
2488+
orig_check_compression(compression)
2489+
2490+
def get_compressor(compress_type, compresslevel=None):
2491+
if compress_type == self.COMPRESSION:
2492+
return self.Compressor()
2493+
return orig_get_compressor(compress_type, compresslevel)
2494+
2495+
def get_decompressor(compress_type):
2496+
if compress_type == self.COMPRESSION:
2497+
return self.Decompressor()
2498+
return orig_get_decompressor(compress_type)
2499+
2500+
self.enterContext(mock.patch.object(
2501+
zipfile, '_check_compression', check_compression))
2502+
self.enterContext(mock.patch.object(
2503+
zipfile, '_get_compressor', get_compressor))
2504+
self.enterContext(mock.patch.object(
2505+
zipfile, '_get_decompressor', get_decompressor))
2506+
2507+
def test_roundtrip_monkeypatched_decompressor(self):
2508+
data = bytes(range(256)) * 8
2509+
buf = io.BytesIO()
2510+
with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf:
2511+
zf.writestr("member", data)
2512+
self.assertIn(data.swapcase(), buf.getvalue())
2513+
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
2514+
self.assertEqual(zf.read("member"), data)
2515+
with zf.open("member") as f:
2516+
self.assertEqual(f.read(100), data[:100])
2517+
self.assertEqual(f.read1(100), data[100:200])
2518+
f.seek(-100, os.SEEK_END)
2519+
self.assertEqual(f.read(), data[-100:])
2520+
# Rewinding past the read buffer re-creates the decompressor.
2521+
f.seek(0)
2522+
self.assertEqual(f.read(), data)
2523+
2524+
24572525
class AbstractBadCrcTests:
24582526
def test_testzip_with_bad_crc(self):
24592527
"""Tests that files with bad CRCs return their name from testzip."""

‎Lib/zipfile.py‎

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

679679
@property
680-
def _needs_input(self):
680+
def needs_input(self):
681681
# While the LZMA properties header is still being buffered, more input
682682
# is required; afterwards defer to the wrapped decompressor so a bounded
683683
# decompress() call can be drained across reads.
@@ -761,13 +761,6 @@ def _get_compressor(compress_type, compresslevel=None):
761761
return None
762762

763763

764-
def _decompressor_needs_input(decompressor):
765-
# bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA
766-
# wrapper keeps it private (_needs_input) to avoid adding public API.
767-
needs_input = getattr(decompressor, "needs_input", None)
768-
return decompressor._needs_input if needs_input is None else needs_input
769-
770-
771764
def _get_decompressor(compress_type):
772765
_check_compression(compress_type)
773766
if compress_type == ZIP_STORED:
@@ -1069,7 +1062,7 @@ def _read1(self, n):
10691062
else:
10701063
# bzip2/lzma/zstd: a bounded decompress() call may leave input
10711064
# buffered inside the decompressor; drain that before reading more.
1072-
if _decompressor_needs_input(self._decompressor):
1065+
if getattr(self._decompressor, "needs_input", True):
10731066
data = self._read2(n)
10741067
else:
10751068
data = b''
@@ -1088,10 +1081,14 @@ def _read1(self, n):
10881081
# Bound the output of a single decompress() call (mirroring the
10891082
# DEFLATE path above) so that a small compressed member cannot
10901083
# expand into one unbounded read.
1091-
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1084+
try:
1085+
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1086+
except TypeError:
1087+
# See MonkeypatchedDecompressorTests in test_core.py
1088+
data = self._decompressor.decompress(data)
10921089
self._eof = (self._decompressor.eof or
10931090
self._compress_left <= 0 and
1094-
_decompressor_needs_input(self._decompressor))
1091+
getattr(self._decompressor, "needs_input", True))
10951092

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