Skip to content

Commit b887db5

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 d6a73ca commit b887db5

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
@@ -2626,6 +2626,74 @@ class LzmaBoundedDecompressTests(AbstractBoundedDecompressTests,
26262626
compression = zipfile.ZIP_LZMA
26272627

26282628

2629+
2630+
class MonkeypatchedDecompressorTests(unittest.TestCase):
2631+
# Some third-party projects monkey-patch _get_decompressor() to add
2632+
# additional compression schemes. This can break at any time as the
2633+
# internal compressor objects change.
2634+
# To protect users, we try to keep this case working.
2635+
# See also: GH-156002 and GH-113767.
2636+
COMPRESSION = 99
2637+
2638+
class Compressor:
2639+
"""Compressor with only the original BZ2Compressor API"""
2640+
def compress(self, data):
2641+
return data.swapcase()
2642+
2643+
def flush(self):
2644+
return b''
2645+
2646+
class Decompressor:
2647+
"""Decompressor with only the 3.3+ BZ2Decompressor API"""
2648+
eof = False
2649+
2650+
def decompress(self, data):
2651+
return data.swapcase()
2652+
2653+
def setUp(self):
2654+
orig_check_compression = zipfile._check_compression
2655+
orig_get_compressor = zipfile._get_compressor
2656+
orig_get_decompressor = zipfile._get_decompressor
2657+
2658+
def check_compression(compression):
2659+
if compression != self.COMPRESSION:
2660+
orig_check_compression(compression)
2661+
2662+
def get_compressor(compress_type, compresslevel=None):
2663+
if compress_type == self.COMPRESSION:
2664+
return self.Compressor()
2665+
return orig_get_compressor(compress_type, compresslevel)
2666+
2667+
def get_decompressor(compress_type):
2668+
if compress_type == self.COMPRESSION:
2669+
return self.Decompressor()
2670+
return orig_get_decompressor(compress_type)
2671+
2672+
self.enterContext(mock.patch.object(
2673+
zipfile, '_check_compression', check_compression))
2674+
self.enterContext(mock.patch.object(
2675+
zipfile, '_get_compressor', get_compressor))
2676+
self.enterContext(mock.patch.object(
2677+
zipfile, '_get_decompressor', get_decompressor))
2678+
2679+
def test_roundtrip_monkeypatched_decompressor(self):
2680+
data = bytes(range(256)) * 8
2681+
buf = io.BytesIO()
2682+
with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf:
2683+
zf.writestr("member", data)
2684+
self.assertIn(data.swapcase(), buf.getvalue())
2685+
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
2686+
self.assertEqual(zf.read("member"), data)
2687+
with zf.open("member") as f:
2688+
self.assertEqual(f.read(100), data[:100])
2689+
self.assertEqual(f.read1(100), data[100:200])
2690+
f.seek(-100, os.SEEK_END)
2691+
self.assertEqual(f.read(), data[-100:])
2692+
# Rewinding past the read buffer re-creates the decompressor.
2693+
f.seek(0)
2694+
self.assertEqual(f.read(), data)
2695+
2696+
26292697
class AbstractBadCrcTests:
26302698
def test_testzip_with_bad_crc(self):
26312699
"""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
@@ -699,7 +699,7 @@ def __init__(self):
699699
self.eof = False
700700

701701
@property
702-
def _needs_input(self):
702+
def needs_input(self):
703703
# While the LZMA properties header is still being buffered, more input
704704
# is required; afterwards defer to the wrapped decompressor so a bounded
705705
# decompress() call can be drained across reads.
@@ -783,13 +783,6 @@ def _get_compressor(compress_type, compresslevel=None):
783783
return None
784784

785785

786-
def _decompressor_needs_input(decompressor):
787-
# bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA
788-
# wrapper keeps it private (_needs_input) to avoid adding public API.
789-
needs_input = getattr(decompressor, "needs_input", None)
790-
return decompressor._needs_input if needs_input is None else needs_input
791-
792-
793786
def _get_decompressor(compress_type):
794787
_check_compression(compress_type)
795788
if compress_type == ZIP_STORED:
@@ -1095,7 +1088,7 @@ def _read1(self, n):
10951088
else:
10961089
# bzip2/lzma/zstd: a bounded decompress() call may leave input
10971090
# buffered inside the decompressor; drain that before reading more.
1098-
if _decompressor_needs_input(self._decompressor):
1091+
if getattr(self._decompressor, "needs_input", True):
10991092
data = self._read2(n)
11001093
else:
11011094
data = b''
@@ -1114,10 +1107,14 @@ def _read1(self, n):
11141107
# Bound the output of a single decompress() call (mirroring the
11151108
# DEFLATE path above) so that a small compressed member cannot
11161109
# expand into one unbounded read.
1117-
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1110+
try:
1111+
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1112+
except TypeError:
1113+
# See MonkeypatchedDecompressorTests in test_core.py
1114+
data = self._decompressor.decompress(data)
11181115
self._eof = (self._decompressor.eof or
11191116
self._compress_left <= 0 and
1120-
_decompressor_needs_input(self._decompressor))
1117+
getattr(self._decompressor, "needs_input", True))
11211118

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