3454a55636
cffconvert / validate (push) Has been cancelled
ci-workflow / pre-commit (push) Has been cancelled
ci-workflow / Minimal NLTK Download Test (macos-latest) (push) Has been cancelled
ci-workflow / Minimal NLTK Download Test (ubuntu-latest) (push) Has been cancelled
ci-workflow / Minimal NLTK Download Test (windows-latest) (push) Has been cancelled
ci-workflow / Python 3.10 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.11 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.12 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.13 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.14 on macos-latest (push) Has been cancelled
ci-workflow / Python 3.14t on macos-latest (push) Has been cancelled
ci-workflow / Python 3.10 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.11 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.12 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.13 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.14 on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.14t on ubuntu-latest (push) Has been cancelled
ci-workflow / Python 3.10 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.11 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.12 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.13 on windows-latest (push) Has been cancelled
ci-workflow / Python 3.14 on windows-latest (push) Has been cancelled
93 lines
3.9 KiB
Python
93 lines
3.9 KiB
Python
"""Regression tests for decompression-bomb protection in NLTK (CWE-409).
|
|
|
|
NLTK reads whole zip members into memory (``ZipFilePathPointer.open`` /
|
|
``OpenOnDemandZipFile.read``) and extracts them to disk (``nltk.downloader``)
|
|
without any cap, so a tiny archive whose member decompresses to gigabytes
|
|
exhausts RAM or disk. ``nltk.data._check_decompression_bomb`` now rejects a
|
|
member that expands beyond ``MAX_UNZIP_RATIO`` (above an activation size) or past
|
|
the optional absolute cap ``MAX_UNZIP_SIZE``. Ordinary corpora compress only a
|
|
few-fold, so the guard does not affect legitimate data.
|
|
"""
|
|
|
|
import os
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
import nltk.data as data
|
|
from nltk.data import ZipFilePathPointer
|
|
from nltk.downloader import ErrorMessage, _unzip_iter
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _restore_limits():
|
|
"""Snapshot and restore the configurable limits around each test."""
|
|
saved = (data.MAX_UNZIP_RATIO, data.MAX_UNZIP_SIZE, data.MAX_UNZIP_ACTIVATION)
|
|
yield
|
|
data.MAX_UNZIP_RATIO, data.MAX_UNZIP_SIZE, data.MAX_UNZIP_ACTIVATION = saved
|
|
|
|
|
|
def _make_zip(path, member, payload):
|
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr(member, payload)
|
|
return path
|
|
|
|
|
|
def test_ratio_guard_blocks_bomb(tmp_path):
|
|
"""A member expanding past MAX_UNZIP_RATIO (above activation) is refused."""
|
|
data.MAX_UNZIP_ACTIVATION = 1024 * 1024 # 1 MiB
|
|
data.MAX_UNZIP_RATIO = 100
|
|
z = _make_zip(tmp_path / "bomb.zip", "m", b"\0" * (2 * 1024 * 1024)) # ~1000x
|
|
with pytest.raises(ValueError, match="zip bomb"):
|
|
ZipFilePathPointer(str(z), "m").open().read()
|
|
|
|
|
|
def test_low_ratio_member_passes(tmp_path):
|
|
"""Incompressible / low-ratio data is never rejected (no false positive)."""
|
|
z = _make_zip(tmp_path / "ok.zip", "m", os.urandom(2 * 1024 * 1024))
|
|
assert len(ZipFilePathPointer(str(z), "m").open().read()) == 2 * 1024 * 1024
|
|
|
|
|
|
def test_small_high_ratio_member_passes(tmp_path):
|
|
"""A small member below the activation size passes regardless of ratio."""
|
|
# 1 MiB of zeros (huge ratio) but below the default 32 MiB activation.
|
|
z = _make_zip(tmp_path / "small.zip", "m", b"\0" * (1024 * 1024))
|
|
assert len(ZipFilePathPointer(str(z), "m").open().read()) == 1024 * 1024
|
|
|
|
|
|
def test_absolute_cap_blocks_oversize(tmp_path):
|
|
"""The optional MAX_UNZIP_SIZE hard cap refuses oversize members."""
|
|
data.MAX_UNZIP_SIZE = 1024 * 1024 # 1 MiB
|
|
z = _make_zip(tmp_path / "big.zip", "m", os.urandom(2 * 1024 * 1024))
|
|
with pytest.raises(ValueError, match="MAX_UNZIP_SIZE"):
|
|
ZipFilePathPointer(str(z), "m").open().read()
|
|
|
|
|
|
def test_downloader_extract_blocks_bomb(tmp_path):
|
|
"""The on-disk extraction path refuses a bomb member before writing it."""
|
|
data.MAX_UNZIP_ACTIVATION = 1024 * 1024 # 1 MiB
|
|
data.MAX_UNZIP_RATIO = 100
|
|
z = _make_zip(tmp_path / "pkg.zip", "pkg/big.txt", b"\0" * (2 * 1024 * 1024))
|
|
dest = tmp_path / "out"
|
|
messages = list(_unzip_iter(str(z), str(dest), verbose=False))
|
|
assert any(isinstance(m, ErrorMessage) for m in messages), messages
|
|
# nothing was written to disk
|
|
assert not (dest / "pkg" / "big.txt").exists()
|
|
|
|
|
|
def test_downloader_writes_nothing_when_a_later_member_is_a_bomb(tmp_path):
|
|
"""A bomb after benign members must be caught before *anything* is written
|
|
(validate-then-extract contract)."""
|
|
data.MAX_UNZIP_ACTIVATION = 1024 * 1024 # 1 MiB
|
|
data.MAX_UNZIP_RATIO = 100
|
|
zp = tmp_path / "pkg.zip"
|
|
with zipfile.ZipFile(zp, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr("pkg/safe.txt", b"hello") # benign member, comes first
|
|
zf.writestr("pkg/big.txt", b"\0" * (2 * 1024 * 1024)) # bomb, comes later
|
|
dest = tmp_path / "out"
|
|
messages = list(_unzip_iter(str(zp), str(dest), verbose=False))
|
|
assert any(isinstance(m, ErrorMessage) for m in messages), messages
|
|
# neither the benign member nor the bomb was written
|
|
assert not (dest / "pkg" / "safe.txt").exists()
|
|
assert not (dest / "pkg" / "big.txt").exists()
|