diff --git a/src/specify_cli/bundler/services/packager.py b/src/specify_cli/bundler/services/packager.py index 4e14934e0a..591beb348d 100644 --- a/src/specify_cli/bundler/services/packager.py +++ b/src/specify_cli/bundler/services/packager.py @@ -14,6 +14,7 @@ from pathlib import Path from .. import BundlerError +from ..._download_security import MAX_ZIP_MEMBER_BYTES from ..lib.yamlio import ensure_within from ..models.manifest import BundleManifest from .validator import validate_manifest @@ -97,7 +98,20 @@ def build_bundle( st = os.fstat(fh.fileno()) mode = 0o755 if st.st_mode & 0o111 else 0o644 info.external_attr = mode << 16 - archive.writestr(info, fh.read()) + # Fast metadata rejection: skip files whose size exceeds the + # limit before touching the read path. Then also bound the + # actual read so a TOCTOU race (file appended after fstat) + # cannot bypass the limit. + if st.st_size > MAX_ZIP_MEMBER_BYTES: + raise BundlerError( + f"Bundle file {arcname} exceeds {MAX_ZIP_MEMBER_BYTES}-byte limit" + ) + content = fh.read(MAX_ZIP_MEMBER_BYTES + 1) + if len(content) > MAX_ZIP_MEMBER_BYTES: + raise BundlerError( + f"Bundle file {arcname} exceeds {MAX_ZIP_MEMBER_BYTES}-byte limit" + ) + archive.writestr(info, content) return BuildResult(artifact_path=artifact_path, file_count=len(files)) diff --git a/tests/unit/test_bundler_packager.py b/tests/unit/test_bundler_packager.py index d203f7ffb0..4ea938432a 100644 --- a/tests/unit/test_bundler_packager.py +++ b/tests/unit/test_bundler_packager.py @@ -10,6 +10,7 @@ from specify_cli.bundler import BundlerError from specify_cli.bundler.services.packager import build_bundle +from specify_cli._download_security import MAX_ZIP_MEMBER_BYTES from tests.bundler_helpers import valid_manifest_dict @@ -234,3 +235,28 @@ def test_toctou_stat_read_consistency(tmp_path: Path): assert content == b"\x00\x01\x02\x03" assert modes["assets/data.bin"] == 0o644 assert modes["README.md"] == 0o644 + + +def test_oversized_asset_file_is_rejected(tmp_path: Path): + """A single file exceeding MAX_ZIP_MEMBER_BYTES must be refused, not read + into memory unbounded.""" + bundle = _make_bundle(tmp_path / "b") + oversized = bundle / "assets" / "huge.bin" + oversized.parent.mkdir(parents=True, exist_ok=True) + oversized.write_bytes(b"\x00" * (MAX_ZIP_MEMBER_BYTES + 1)) + + with pytest.raises(BundlerError, match="exceeds.*byte limit"): + build_bundle(bundle, output_dir=tmp_path / "out") + + +def test_asset_at_exact_size_limit_is_accepted(tmp_path: Path): + """A file exactly at MAX_ZIP_MEMBER_BYTES must still be packaged.""" + bundle = _make_bundle(tmp_path / "b") + at_limit = bundle / "assets" / "exact.bin" + at_limit.parent.mkdir(parents=True, exist_ok=True) + at_limit.write_bytes(b"\x00" * MAX_ZIP_MEMBER_BYTES) + + result = build_bundle(bundle, output_dir=tmp_path / "out") + with zipfile.ZipFile(result.artifact_path) as archive: + content = archive.read("assets/exact.bin") + assert len(content) == MAX_ZIP_MEMBER_BYTES