gh-156002: Bound zipfile decompression for bzip2/LZMA/Zstandard (GH-156003)
Patch by @tonghuaroot.
zipfile.ZipExtFile._read1() bounds the output of each decompress() call
for DEFLATE members by passing a max_length to zlib, but for bzip2, LZMA,
and Zstandard members it called decompress() with no bound. A whole
compressed chunk was therefore expanded into a single allocation before
the data[:self._left] clip ran, so a consumer that deliberately reads in
small chunks to limit memory (for example zf.open(name).read(8192)) was
silently unprotected for non-DEFLATE members. A small, spec-conformant
archive member declaring a large uncompressed size could drive multi-GB
peak memory.
_read1() now passes a per-call bound to the non-DEFLATE decompress()
(mirroring the DEFLATE branch) and drains the decompressor's internal
buffer across calls by checking needs_input before reading more
compressed input. zipfile's LZMADecompressor wrapper forwards max_length
and exposes needs_input so the bound also holds for LZMA members.
Co-authored-by: tonghuaroot <tonghuaroot@gmail.com>
diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py
index 1c6e3a9..fdf2cd2 100644
--- a/Lib/test/test_zipfile/test_core.py
+++ b/Lib/test/test_zipfile/test_core.py
@@ -4874,6 +4874,48 @@ def tearDown(self):
unlink(TESTFN2)
+class AbstractBoundedDecompressTests:
+ # ZipExtFile._read1() bounds the output of each decompress() call so that a
+ # small member declaring a large uncompressed size cannot expand into one
+ # unbounded read.
+ def test_read1_output_is_bounded(self):
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", compression=self.compression) as zf:
+ zf.writestr("big", b"\0" * (4 * 1024 * 1024))
+ with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
+ with zf.open("big") as f:
+ self.assertLessEqual(len(f._read1(100)), f.MIN_READ_SIZE)
+
+
+class StoredBoundedDecompressTests(AbstractBoundedDecompressTests,
+ unittest.TestCase):
+ compression = zipfile.ZIP_STORED
+
+
+@requires_zlib()
+class DeflateBoundedDecompressTests(AbstractBoundedDecompressTests,
+ unittest.TestCase):
+ compression = zipfile.ZIP_DEFLATED
+
+
+@requires_bz2()
+class Bzip2BoundedDecompressTests(AbstractBoundedDecompressTests,
+ unittest.TestCase):
+ compression = zipfile.ZIP_BZIP2
+
+
+@requires_lzma()
+class LzmaBoundedDecompressTests(AbstractBoundedDecompressTests,
+ unittest.TestCase):
+ compression = zipfile.ZIP_LZMA
+
+
+@requires_zstd()
+class ZstdBoundedDecompressTests(AbstractBoundedDecompressTests,
+ unittest.TestCase):
+ compression = zipfile.ZIP_ZSTANDARD
+
+
class AbstractBadCrcTests:
def test_testzip_with_bad_crc(self):
"""Tests that files with bad CRCs return their name from testzip."""
diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py
index 7a81aa8..0accf32 100644
--- a/Lib/zipfile/__init__.py
+++ b/Lib/zipfile/__init__.py
@@ -801,7 +801,16 @@ def unused_data(self):
except AttributeError:
return b''
- def decompress(self, data):
+ @property
+ def _needs_input(self):
+ # While the LZMA properties header is still being buffered, more input
+ # is required; afterwards defer to the wrapped decompressor so a bounded
+ # decompress() call can be drained across reads.
+ if self._decomp is None:
+ return True
+ return self._decomp.needs_input
+
+ def decompress(self, data, max_length=-1):
if self._decomp is None:
self._unconsumed += data
if len(self._unconsumed) <= 4:
@@ -817,7 +826,7 @@ def decompress(self, data):
data = self._unconsumed[4 + psize:]
del self._unconsumed
- result = self._decomp.decompress(data)
+ result = self._decomp.decompress(data, max_length)
self.eof = self._decomp.eof
return result
@@ -884,6 +893,13 @@ def _get_compressor(compress_type, compresslevel=None):
return None
+def _decompressor_needs_input(decompressor):
+ # bz2/zstd expose the stdlib decompressor's public needs_input; the LZMA
+ # wrapper keeps it private (_needs_input) to avoid adding public API.
+ needs_input = getattr(decompressor, "needs_input", None)
+ return decompressor._needs_input if needs_input is None else needs_input
+
+
def _get_decompressor(compress_type):
_check_compression(compress_type)
if compress_type == ZIP_STORED:
@@ -1186,8 +1202,15 @@ def _read1(self, n):
data = self._decompressor.unconsumed_tail
if n > len(data):
data += self._read2(n - len(data))
- else:
+ elif self._compress_type == ZIP_STORED:
data = self._read2(n)
+ else:
+ # bzip2/lzma/zstd: a bounded decompress() call may leave input
+ # buffered inside the decompressor; drain that before reading more.
+ if _decompressor_needs_input(self._decompressor):
+ data = self._read2(n)
+ else:
+ data = b''
if self._compress_type == ZIP_STORED:
self._eof = self._compress_left <= 0
@@ -1200,8 +1223,13 @@ def _read1(self, n):
if self._eof:
data += self._decompressor.flush()
else:
- data = self._decompressor.decompress(data)
- self._eof = self._decompressor.eof or self._compress_left <= 0
+ # Bound the output of a single decompress() call (mirroring the
+ # DEFLATE path above) so that a small compressed member cannot
+ # expand into one unbounded read.
+ data = self._decompressor.decompress(data, max(n, self.MIN_READ_SIZE))
+ self._eof = (self._decompressor.eof or
+ self._compress_left <= 0 and
+ _decompressor_needs_input(self._decompressor))
data = data[:self._left]
self._left -= len(data)
diff --git a/Misc/NEWS.d/next/Security/2026-08-18-13-54-05.gh-issue-156002.CcWXPP.rst b/Misc/NEWS.d/next/Security/2026-08-18-13-54-05.gh-issue-156002.CcWXPP.rst
new file mode 100644
index 0000000..4e49ad5
--- /dev/null
+++ b/Misc/NEWS.d/next/Security/2026-08-18-13-54-05.gh-issue-156002.CcWXPP.rst
@@ -0,0 +1,4 @@
+Bound the amount of data :mod:`zipfile` decompresses per read for members
+compressed with bzip2, LZMA, or Zstandard, matching the existing limit for
+deflate. A small archive member could previously expand into an unbounded
+allocation even when read in small chunks.