Skip to content

Commit c6b8aab

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 3696a46 commit c6b8aab

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
@@ -2234,6 +2234,74 @@ class LzmaBoundedDecompressTests(AbstractBoundedDecompressTests,
22342234
compression = zipfile.ZIP_LZMA
22352235

22362236

2237+
2238+
class MonkeypatchedDecompressorTests(unittest.TestCase):
2239+
# Some third-party projects monkey-patch _get_decompressor() to add
2240+
# additional compression schemes. This can break at any time as the
2241+
# internal compressor objects change.
2242+
# To protect users, we try to keep this case working.
2243+
# See also: GH-156002 and GH-113767.
2244+
COMPRESSION = 99
2245+
2246+
class Compressor:
2247+
"""Compressor with only the original BZ2Compressor API"""
2248+
def compress(self, data):
2249+
return data.swapcase()
2250+
2251+
def flush(self):
2252+
return b''
2253+
2254+
class Decompressor:
2255+
"""Decompressor with only the 3.3+ BZ2Decompressor API"""
2256+
eof = False
2257+
2258+
def decompress(self, data):
2259+
return data.swapcase()
2260+
2261+
def setUp(self):
2262+
orig_check_compression = zipfile._check_compression
2263+
orig_get_compressor = zipfile._get_compressor
2264+
orig_get_decompressor = zipfile._get_decompressor
2265+
2266+
def check_compression(compression):
2267+
if compression != self.COMPRESSION:
2268+
orig_check_compression(compression)
2269+
2270+
def get_compressor(compress_type, compresslevel=None):
2271+
if compress_type == self.COMPRESSION:
2272+
return self.Compressor()
2273+
return orig_get_compressor(compress_type, compresslevel)
2274+
2275+
def get_decompressor(compress_type):
2276+
if compress_type == self.COMPRESSION:
2277+
return self.Decompressor()
2278+
return orig_get_decompressor(compress_type)
2279+
2280+
self.enterContext(mock.patch.object(
2281+
zipfile, '_check_compression', check_compression))
2282+
self.enterContext(mock.patch.object(
2283+
zipfile, '_get_compressor', get_compressor))
2284+
self.enterContext(mock.patch.object(
2285+
zipfile, '_get_decompressor', get_decompressor))
2286+
2287+
def test_roundtrip_monkeypatched_decompressor(self):
2288+
data = bytes(range(256)) * 8
2289+
buf = io.BytesIO()
2290+
with zipfile.ZipFile(buf, "w", compression=self.COMPRESSION) as zf:
2291+
zf.writestr("member", data)
2292+
self.assertIn(data.swapcase(), buf.getvalue())
2293+
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
2294+
self.assertEqual(zf.read("member"), data)
2295+
with zf.open("member") as f:
2296+
self.assertEqual(f.read(100), data[:100])
2297+
self.assertEqual(f.read1(100), data[100:200])
2298+
f.seek(-100, os.SEEK_END)
2299+
self.assertEqual(f.read(), data[-100:])
2300+
# Rewinding past the read buffer re-creates the decompressor.
2301+
f.seek(0)
2302+
self.assertEqual(f.read(), data)
2303+
2304+
22372305
class AbstractBadCrcTests:
22382306
def test_testzip_with_bad_crc(self):
22392307
"""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
@@ -641,7 +641,7 @@ def __init__(self):
641641
self.eof = False
642642

643643
@property
644-
def _needs_input(self):
644+
def needs_input(self):
645645
# While the LZMA properties header is still being buffered, more input
646646
# is required; afterwards defer to the wrapped decompressor so a bounded
647647
# decompress() call can be drained across reads.
@@ -725,13 +725,6 @@ def _get_compressor(compress_type, compresslevel=None):
725725
return None
726726

727727

728-
def _decompressor_needs_input(decompressor):
729-
# bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA
730-
# wrapper keeps it private (_needs_input) to avoid adding public API.
731-
needs_input = getattr(decompressor, "needs_input", None)
732-
return decompressor._needs_input if needs_input is None else needs_input
733-
734-
735728
def _get_decompressor(compress_type):
736729
_check_compression(compress_type)
737730
if compress_type == ZIP_STORED:
@@ -1033,7 +1026,7 @@ def _read1(self, n):
10331026
else:
10341027
# bzip2/lzma/zstd: a bounded decompress() call may leave input
10351028
# buffered inside the decompressor; drain that before reading more.
1036-
if _decompressor_needs_input(self._decompressor):
1029+
if getattr(self._decompressor, "needs_input", True):
10371030
data = self._read2(n)
10381031
else:
10391032
data = b''
@@ -1052,10 +1045,14 @@ def _read1(self, n):
10521045
# Bound the output of a single decompress() call (mirroring the
10531046
# DEFLATE path above) so that a small compressed member cannot
10541047
# expand into one unbounded read.
1055-
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1048+
try:
1049+
data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
1050+
except TypeError:
1051+
# See MonkeypatchedDecompressorTests in test_core.py
1052+
data = self._decompressor.decompress(data)
10561053
self._eof = (self._decompressor.eof or
10571054
self._compress_left <= 0 and
1058-
_decompressor_needs_input(self._decompressor))
1055+
getattr(self._decompressor, "needs_input", True))
10591056

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