Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions openaddr/conform.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,17 @@ class ZipDecompressTask(DecompressionTask):
# any per-entry file type filtering applies. Cap the declared
# (uncompressed) size of any single entry and how many nested zips deep
# we'll recurse, so worst case is bounded and the job fails loudly
# instead of exhausting disk.
MAX_ZIP_ENTRY_BYTES = 2 * 1024 ** 3 # 2GB
# instead of exhausting disk. The per-entry cap defaults to 8GiB and can
# be overridden with the BATCH_MACHINE_MAX_ZIP_ENTRY_BYTES env var.
MAX_ZIP_ENTRY_BYTES = 8 * 1024 ** 3 # 8GiB
MAX_NESTED_ZIP_DEPTH = 10

def _max_entry_bytes(self):
override = os.environ.get('BATCH_MACHINE_MAX_ZIP_ENTRY_BYTES')
if override:
return int(override)
return self.MAX_ZIP_ENTRY_BYTES

def decompress(self, source_paths, workdir, filenames):
output_files = []
expand_path = os.path.join(workdir, UNZIPPED_DIRNAME)
Expand Down Expand Up @@ -283,17 +290,18 @@ def _extract_zip(self, source_path, expand_path, filenames):
further nested zips.
'''
found = set()
max_entry_bytes = self._max_entry_bytes()
with ZipFile(source_path, 'r') as z:
for zinfo in z.infolist():
name = zinfo.filename

# Check the declared (uncompressed) size from the zip's
# central directory before extracting anything - a bomb's
# compressed size can be tiny, but its declared size isn't.
if zinfo.file_size > self.MAX_ZIP_ENTRY_BYTES:
if zinfo.file_size > max_entry_bytes:
raise DecompressionError(
"Refusing to extract {} - declared size {} bytes exceeds {} byte limit, possible zip bomb"
.format(name, zinfo.file_size, self.MAX_ZIP_ENTRY_BYTES)
.format(name, zinfo.file_size, max_entry_bytes)
)

# Nested zip files are always extracted regardless of the
Expand Down
31 changes: 31 additions & 0 deletions openaddr/tests/conform.py
Original file line number Diff line number Diff line change
Expand Up @@ -2928,6 +2928,37 @@ def test_oversized_entry_raises_decompression_error(self):
with self.assertRaises(DecompressionError):
task.decompress([outer_zip_path], self.workdir, [])

def test_entry_under_limit_extracts(self):
outer_zip_path = self._make_outer_zip()

task = ZipDecompressTask()
with mock.patch.object(ZipDecompressTask, 'MAX_ZIP_ENTRY_BYTES', 10 * 1024 * 1024):
output_files = task.decompress([outer_zip_path], self.workdir, [])

self.assertIn('readme.txt', {os.path.basename(path) for path in output_files})

def test_default_entry_limit_admits_entries_over_two_gib(self):
self.assertEqual(ZipDecompressTask.MAX_ZIP_ENTRY_BYTES, 8 * 1024 ** 3)
self.assertGreater(ZipDecompressTask.MAX_ZIP_ENTRY_BYTES, 2540059567)

def test_entry_limit_env_override_refuses_oversized_entry(self):
outer_zip_path = self._make_outer_zip()

task = ZipDecompressTask()
with mock.patch.dict(os.environ, {'BATCH_MACHINE_MAX_ZIP_ENTRY_BYTES': '10'}):
with self.assertRaises(DecompressionError):
task.decompress([outer_zip_path], self.workdir, [])

def test_entry_limit_env_override_raises_cap(self):
outer_zip_path = self._make_outer_zip()

task = ZipDecompressTask()
with mock.patch.object(ZipDecompressTask, 'MAX_ZIP_ENTRY_BYTES', 10):
with mock.patch.dict(os.environ, {'BATCH_MACHINE_MAX_ZIP_ENTRY_BYTES': str(10 * 1024 * 1024)}):
output_files = task.decompress([outer_zip_path], self.workdir, [])

self.assertIn('readme.txt', {os.path.basename(path) for path in output_files})

def test_deeply_nested_zips_raise_decompression_error(self):
''' A chain of nested zips deeper than the configured limit should
be refused, as a zip bomb guard against chained amplification.
Expand Down
Loading