Files
github-spec-kit/tests/test_download_security.py
Pascal THUET 118062eac4 harden: secure extension and preset archive downloads (#3141)
* harden: secure extension and preset archive downloads

Adopt the shared download-security primitives from #3140 across extension and
preset catalog, package, direct-URL, and ZIP-install flows:

- bound catalog, package, and inline manifest reads;
- verify catalog SHA-256 values when present;
- replace path-only extraction with bounded traversal/symlink-safe extraction;
- validate malformed hosts and ports before opening download URLs;
- handle normalized trailing-backslash directory entries consistently.

Redirect enforcement and checksum verification remain owned by the shared
helpers already on main; this commit wires them into extension and preset
behavior.

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: close archive and catalog download edge cases

Preflight ZIP central directories before ZipFile allocates them, bound both
declared and actual payload sizes, and reject ambiguous or non-portable archive
paths before extraction.

Keep extension update manifest selection consistent with extraction, reject
unsafe catalog-derived output filenames and malformed URL types, and escape
untrusted values in download errors.

Add regression coverage for parser differentials, collisions, platform-specific
filenames, bounded call sites, and failure ordering.

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: address download security review feedback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: close ZIP preflight review gaps

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* fix: harden extension update preflight and rollback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* fix: harden extension update rollback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
2026-07-28 07:52:07 -05:00

1072 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for bounded download and ZIP extraction helpers."""
from __future__ import annotations
import io
import stat
import struct
import weakref
import zipfile
import zlib
import pytest
from specify_cli._download_security import (
MAX_ZIP_CENTRAL_DIRECTORY_BYTES,
build_safe_download_path,
is_https_or_localhost_http,
is_loopback_url,
read_response_limited,
read_zip_member_limited,
safe_extract_zip,
)
@pytest.mark.parametrize(
"url, allowed",
[
("https://example.com/preset.zip", True),
("http://localhost:8000/preset.zip", True),
("http://127.0.0.1/preset.zip", True),
("http://127.0.0.2/preset.zip", True),
("http://127.255.255.254/preset.zip", True),
("http://[::1]/preset.zip", True),
("http://[0:0:0:0:0:0:0:1]/preset.zip", True),
("http://[::ffff:127.0.0.2]/preset.zip", True),
("http://[::1%25lo0]/preset.zip", True),
# Non-loopback HTTP is rejected.
("http://example.com/preset.zip", False),
("http://192.0.2.1/preset.zip", False),
("http://[fe80::1]/preset.zip", False),
("http://[fe80::1%25lo0]/preset.zip", False),
("http://0.0.0.0/preset.zip", False),
("http://0/preset.zip", False),
("http://[::]/preset.zip", False),
("http://[::ffff:0.0.0.0]/preset.zip", False),
# Ambiguous/platform-dependent spellings may never authorize HTTP.
("http://127.1/preset.zip", False),
("http://2130706433/preset.zip", False),
("http://0x7f000001/preset.zip", False),
("http://017700000001/preset.zip", False),
("http://0177.0.0.1/preset.zip", False),
("http://00177.0.0.1/preset.zip", False),
("http://localhost./preset.zip", False),
("http://ocalhost/preset.zip", False),
("http://127。0。0。1/preset.zip", False),
# A hostname is always required, even for HTTPS.
("https:///preset.zip", False),
("https://", False),
# Invalid ports must be rejected before urllib opens the URL.
("https://example.com:notaport/preset.zip", False),
("https://example.com:+443/preset.zip", False),
("https://example.com:65536/preset.zip", False),
# urllib decodes escapes in the authority before connecting; reject
# encoded reg-names so validation and connection cannot disagree.
("https://127%2e0%2e0%2e1/preset.zip", False),
("https://%31%32%37.0.0.1/preset.zip", False),
("https://local%68ost/preset.zip", False),
("https://example.com%3a443/preset.zip", False),
("https://[::1%lo0]/preset.zip", False),
("https://[::ffff:127%2e0.0.1]/preset.zip", False),
("https://[::ffff:7f00%3a1]/preset.zip", False),
("https://[::ffff%3a127.0.0.1]/preset.zip", False),
],
)
def test_is_https_or_localhost_http(url, allowed):
assert is_https_or_localhost_http(url) is allowed
@pytest.mark.parametrize(
"url",
[
"https://localhost/internal",
"https://127.0.0.2/internal",
"https://[::1]/internal",
"https://[::1%25lo0]/internal",
"https://[::ffff:127.0.0.2]/internal",
],
)
def test_is_loopback_url_recognizes_effective_loopback_literals(url):
assert is_loopback_url(url) is True
@pytest.mark.parametrize(
"url",
[
"https://localhost./internal",
"https://service.localhost/internal",
"https://service.localhost./internal",
"https://127.1/internal",
"https://2130706433/internal",
"https://0x7f000001/internal",
"https://017700000001/internal",
"https://0177.0.0.1/internal",
"https://ocalhost/internal",
"https://127。0。0。1/internal",
"https://127%2e0%2e0%2e1/internal",
"https://0.0.0.0/internal",
"https://0/internal",
"https://00.00.00.00/internal",
"https://[::]/internal",
"https://[::ffff:0.0.0.0]/internal",
],
)
def test_is_loopback_url_does_not_authorize_ambiguous_spellings(url):
assert is_loopback_url(url) is False
class _Response:
"""Faithful stream stand-in: read() advances a cursor and returns b"" at EOF."""
def __init__(self, data: bytes, *, chunk: int | None = None):
self.data = data
self.pos = 0
self.chunk = chunk
def read(self, size: int = -1) -> bytes:
if size < 0:
size = len(self.data) - self.pos
if self.chunk is not None:
size = min(size, self.chunk)
out = self.data[self.pos : self.pos + size]
self.pos += len(out)
return out
class _RecordingResponse(_Response):
def __init__(self, data: bytes, *, chunk: int | None = None):
super().__init__(data, chunk=chunk)
self.requested_sizes: list[int] = []
def read(self, size: int = -1) -> bytes:
self.requested_sizes.append(size)
return super().read(size)
class _TrackedChunk(bytearray):
pass
class _OneByteResponse:
"""Return distinct weak-referenceable chunks to detect retained fragments."""
def __init__(self, count: int):
self.remaining = count
self.refs: list[weakref.ReferenceType[_TrackedChunk]] = []
self.peak_live = 0
def read(self, _size: int = -1) -> bytes | _TrackedChunk:
if self.remaining == 0:
return b""
self.remaining -= 1
chunk = _TrackedChunk(b"x")
self.refs.append(weakref.ref(chunk))
self.peak_live = max(
self.peak_live,
sum(ref() is not None for ref in self.refs),
)
return chunk
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc, _tb):
return False
class _CustomZipError(ValueError):
pass
class _ExplodingResponse:
def read(self, _size: int = -1) -> bytes:
raise zlib.error("corrupt compressed data")
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc, _tb):
return False
class _FakeZipArchive:
def __init__(
self,
response,
*,
filename: str = "extension.yml",
file_size: int = 0,
):
self.response = response
self.info = zipfile.ZipInfo(filename)
self.info.file_size = file_size
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc, _tb):
return False
def getinfo(self, _name):
return self.info
def infolist(self):
return [self.info]
def open(self, _member, _mode="r"):
return self.response
def test_read_response_limited_rejects_oversized_download():
with pytest.raises(ValueError, match="exceeds maximum size"):
read_response_limited(_Response(b"abcde"), max_bytes=4)
def test_read_response_limited_returns_full_body_within_limit():
assert read_response_limited(_Response(b"abcde"), max_bytes=10) == b"abcde"
def test_read_response_limited_enforces_bound_under_short_reads():
response = _Response(b"x" * 100, chunk=8)
with pytest.raises(ValueError, match="exceeds maximum size"):
read_response_limited(response, max_bytes=16)
def test_read_response_limited_does_not_retain_short_read_fragments():
response = _OneByteResponse(64)
assert read_response_limited(response, max_bytes=64) == b"x" * 64
assert response.peak_live <= 2
def test_read_response_limited_caps_underlying_reads_at_64_kib():
response = _RecordingResponse(b"x" * (64 * 1024 + 1))
with pytest.raises(ValueError, match="exceeds maximum size"):
read_response_limited(response, max_bytes=64 * 1024)
assert max(response.requested_sizes) <= 64 * 1024
@pytest.mark.parametrize("value", [None, "1", 1.5, True])
def test_read_response_limited_rejects_non_integer_limits(value):
with pytest.raises(TypeError, match="integer"):
read_response_limited(_Response(b""), max_bytes=value)
def test_read_response_limited_rejects_negative_limit_without_reading():
response = _RecordingResponse(b"")
with pytest.raises(ValueError, match="non-negative"):
read_response_limited(response, max_bytes=-1)
assert response.requested_sizes == []
def test_read_response_limited_allows_empty_response_at_zero_limit():
assert read_response_limited(_Response(b""), max_bytes=0) == b""
class _CustomLimitError(Exception):
pass
def test_read_response_limited_rejects_first_byte_at_zero_limit():
with pytest.raises(_CustomLimitError, match="exceeds maximum size"):
read_response_limited(
_Response(b"x"),
max_bytes=0,
error_type=_CustomLimitError,
)
def test_read_response_limited_escapes_control_characters_in_label():
with pytest.raises(ValueError) as exc_info:
read_response_limited(
_Response(b"x"),
max_bytes=0,
label="bad\x1b[2J download",
)
assert "\x1b" not in str(exc_info.value)
assert "\\x1b" in str(exc_info.value)
@pytest.mark.parametrize(
"identifier",
[
"../outside",
"..\\outside",
"a" * 256,
"delete\x7f",
"csi\x9b[2J",
"\ud800",
],
)
def test_build_safe_download_path_rejects_nonportable_identifiers(
tmp_path, identifier
):
with pytest.raises(ValueError, match="Unsafe archive download filename"):
build_safe_download_path(
tmp_path,
identifier,
"1.0.0",
)
@pytest.mark.parametrize(
"member_name",
[
"../evil.txt",
"nested/../../evil.txt",
"nested\\..\\evil.txt",
"C:\\Windows\\evil.txt",
"C:drive-relative.txt",
],
)
def test_safe_extract_zip_rejects_traversal(tmp_path, member_name):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "nope")
with pytest.raises(ValueError, match="Unsafe path"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("member_name", [".", "./file.txt", "nested/./file.txt", "nested//file.txt"])
def test_safe_extract_zip_rejects_dot_path_segments(tmp_path, member_name):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "nope")
with pytest.raises(_CustomZipError, match="Unsafe path"):
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
def test_safe_extract_zip_rejects_symlinks(tmp_path):
zip_path = tmp_path / "bad.zip"
info = zipfile.ZipInfo("link")
info.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(info, "target")
with pytest.raises(ValueError, match="Unsafe symlink"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_symlink_without_partial_extraction(tmp_path):
zip_path = tmp_path / "mixed.zip"
link = zipfile.ZipInfo("evil-link")
link.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("safe/first.txt", "hello")
zf.writestr(link, "target")
zf.writestr("safe/second.txt", "world")
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Unsafe symlink"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
def test_safe_extract_zip_rejects_oversized_member(tmp_path):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("big.txt", "abcde")
with pytest.raises(ValueError, match="exceeds maximum size"):
safe_extract_zip(zip_path, tmp_path / "out", max_member_bytes=4)
def test_safe_extract_zip_rejects_too_many_entries(tmp_path):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("one.txt", "1")
zf.writestr("two.txt", "2")
with pytest.raises(ValueError, match="too many entries"):
safe_extract_zip(zip_path, tmp_path / "out", max_entries=1)
def _legacy_zip_eocd(
*,
entries: int,
central_directory_size: int,
central_directory_offset: int = 0,
comment_size: int = 0,
) -> bytes:
return struct.pack(
"<4s4H2LH",
b"PK\x05\x06",
0,
0,
entries,
entries,
central_directory_size,
central_directory_offset,
comment_size,
)
def test_safe_extract_zip_preflights_declared_entry_count(tmp_path, monkeypatch):
zip_path = tmp_path / "too-many.zip"
zip_path.write_bytes(
_legacy_zip_eocd(entries=513, central_directory_size=0)
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="too many entries"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_preflights_actual_entry_count_when_eocd_lies(
tmp_path, monkeypatch
):
central_header = b"PK\x01\x02" + b"\x00" * 42
central_directory = central_header * 513
zip_path = tmp_path / "lying-count.zip"
zip_path.write_bytes(
central_directory
+ _legacy_zip_eocd(
entries=1,
central_directory_size=len(central_directory),
)
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="too many entries"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_truncated_last_eocd_comment(
tmp_path, monkeypatch
):
trailing_eocd = _legacy_zip_eocd(
entries=0,
central_directory_size=0,
comment_size=1,
)
zip_path = tmp_path / "ambiguous-eocd.zip"
zip_path.write_bytes(
_legacy_zip_eocd(
entries=0,
central_directory_size=0,
comment_size=len(trailing_eocd),
)
+ trailing_eocd
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="Invalid ZIP archive"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_zip64_before_zipfile_construction(
tmp_path, monkeypatch
):
zip64_eocd = struct.pack(
"<4sQ2H2L4Q",
b"PK\x06\x06",
44,
45,
45,
0,
0,
0,
0,
0,
0,
)
zip64_locator = struct.pack(
"<4sLQL",
b"PK\x06\x07",
0,
0,
1,
)
zip_path = tmp_path / "zip64.zip"
zip_path.write_bytes(
zip64_eocd
+ zip64_locator
+ _legacy_zip_eocd(
entries=0xFFFF,
central_directory_size=0xFFFFFFFF,
central_directory_offset=0xFFFFFFFF,
)
)
with zipfile.ZipFile(zip_path) as zf:
assert zf.namelist() == []
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize(
"indicator",
[
"central-sizes",
"central-offset",
"central-disk",
"local-sizes",
],
)
def test_safe_extract_zip_rejects_entry_zip64_before_zipfile_construction(
tmp_path, monkeypatch, indicator
):
contents = b"contents"
if indicator == "central-offset":
zip64_payload = struct.pack("<Q", 0)
elif indicator == "central-disk":
zip64_payload = struct.pack("<L", 0)
else:
zip64_payload = struct.pack("<QQ", len(contents), len(contents))
info = zipfile.ZipInfo("file.txt")
info.extra = struct.pack("<HH", 0xCAFE, len(zip64_payload)) + zip64_payload
zip_path = tmp_path / f"{indicator}.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(info, contents)
archive = bytearray(zip_path.read_bytes())
local_header = archive.index(b"PK\x03\x04")
central_header = archive.index(b"PK\x01\x02")
if indicator.startswith("central"):
filename_size = struct.unpack_from("<H", archive, central_header + 28)[0]
extra_offset = central_header + 46 + filename_size
struct.pack_into("<H", archive, extra_offset, 0x0001)
if indicator == "central-sizes":
struct.pack_into("<LL", archive, central_header + 20, 0xFFFFFFFF, 0xFFFFFFFF)
elif indicator == "central-offset":
struct.pack_into("<L", archive, central_header + 42, 0xFFFFFFFF)
else:
struct.pack_into("<H", archive, central_header + 34, 0xFFFF)
else:
filename_size = struct.unpack_from("<H", archive, local_header + 26)[0]
extra_offset = local_header + 30 + filename_size
struct.pack_into("<H", archive, extra_offset, 0x0001)
struct.pack_into("<LL", archive, local_header + 18, 0xFFFFFFFF, 0xFFFFFFFF)
zip_path.write_bytes(archive)
# The stdlib accepts each hybrid ZIP64 entry. The bounded opener must reject
# it during preflight, before handing the archive to ZipFile.
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == contents
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("header_kind", ["central", "local"])
def test_safe_extract_zip_rejects_zip64_extra_without_sentinel_before_zipfile(
tmp_path, monkeypatch, header_kind
):
info = zipfile.ZipInfo("file.txt")
info.extra = struct.pack("<HH", 0xCAFE, 0)
zip_path = tmp_path / f"{header_kind}-extra.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(info, b"contents")
archive = bytearray(zip_path.read_bytes())
if header_kind == "central":
header_offset = archive.index(b"PK\x01\x02")
filename_size = struct.unpack_from("<H", archive, header_offset + 28)[0]
extra_offset = header_offset + 46 + filename_size
else:
header_offset = archive.index(b"PK\x03\x04")
filename_size = struct.unpack_from("<H", archive, header_offset + 26)[0]
extra_offset = header_offset + 30 + filename_size
struct.pack_into("<H", archive, extra_offset, 0x0001)
zip_path.write_bytes(archive)
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == b"contents"
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_force_zip64_local_header_before_zipfile(
tmp_path, monkeypatch
):
zip_path = tmp_path / "forced-local-zip64.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
with zf.open("file.txt", "w", force_zip64=True) as target:
target.write(b"contents")
# For a small streamed member, ZipFile leaves the central directory and
# EOCD legacy-sized while placing ZIP64 sentinels and extra data locally.
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == b"contents"
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("extract_version", [45, 46])
@pytest.mark.parametrize("visible_version", ["central", "local"])
def test_safe_extract_zip_rejects_masked_zip64_data_descriptor_version_or_newer(
tmp_path, monkeypatch, visible_version, extract_version
):
class UnseekableBuffer(io.BytesIO):
def seek(self, *_args, **_kwargs):
raise io.UnsupportedOperation
stream = UnseekableBuffer()
with zipfile.ZipFile(stream, "w") as zf:
with zf.open("file.txt", "w", force_zip64=True) as target:
target.write(b"contents")
archive = bytearray(stream.getvalue())
local_header = archive.index(b"PK\x03\x04")
central_header = archive.index(b"PK\x01\x02")
assert struct.unpack_from("<H", archive, local_header + 6)[0] & 0x0008
assert struct.unpack_from("<H", archive, local_header + 4)[0] == 45
assert struct.unpack_from("<H", archive, central_header + 6)[0] == 45
# Hide the local size sentinels and ZIP64 extra ID while retaining the
# 64-bit data descriptor emitted by ZipFile. Leave a ZIP64-or-newer
# extractor version visible in exactly one header to exercise both
# preflight checks.
struct.pack_into("<LL", archive, local_header + 18, 0, 0)
filename_size = struct.unpack_from("<H", archive, local_header + 26)[0]
local_extra = local_header + 30 + filename_size
struct.pack_into("<H", archive, local_extra, 0xCAFE)
struct.pack_into("<H", archive, local_header + 4, 20)
struct.pack_into("<H", archive, central_header + 6, 20)
if visible_version == "central":
struct.pack_into("<H", archive, central_header + 6, extract_version)
else:
struct.pack_into("<H", archive, local_header + 4, extract_version)
zip_path = tmp_path / f"masked-{visible_version}-v{extract_version}.zip"
zip_path.write_bytes(archive)
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == b"contents"
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="extractor version 4.5 or newer"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("compression", [zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA])
def test_safe_extract_zip_rejects_unbounded_compression_before_zipfile(
tmp_path, monkeypatch, compression
):
zip_path = tmp_path / f"unsupported-{compression}.zip"
with zipfile.ZipFile(zip_path, "w", compression=compression) as zf:
zf.writestr("bomb.txt", b"A" * (1024 * 1024))
# Lie about the output size. For BZIP2/LZMA, ZipExtFile materializes the
# whole decompressor result before slicing it to the requested length.
archive = bytearray(zip_path.read_bytes())
central_header = archive.index(b"PK\x01\x02")
struct.pack_into("<L", archive, central_header + 24, 1)
zip_path.write_bytes(archive)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="supports only STORED and DEFLATED"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize(
"compression",
[zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED],
)
def test_safe_extract_zip_accepts_bounded_compression_methods(
tmp_path, compression
):
zip_path = tmp_path / f"supported-{compression}.zip"
with zipfile.ZipFile(zip_path, "w", compression=compression) as zf:
zf.writestr("file.txt", b"contents")
out_dir = tmp_path / "out"
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "file.txt").read_bytes() == b"contents"
def test_safe_extract_zip_accepts_archive_with_prepended_data(tmp_path):
zip_path = tmp_path / "prefixed.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("file.txt", "contents")
zip_path.write_bytes(b"launcher-prefix" + zip_path.read_bytes())
out_dir = tmp_path / "out"
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "file.txt").read_text(encoding="utf-8") == "contents"
def test_safe_extract_zip_rejects_central_entry_from_another_disk(tmp_path):
zip_path = tmp_path / "multi-disk-entry.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("file.txt", "contents")
archive = bytearray(zip_path.read_bytes())
central_header = archive.index(b"PK\x01\x02")
struct.pack_into("<H", archive, central_header + 34, 1)
zip_path.write_bytes(archive)
with pytest.raises(ValueError, match="Multi-disk"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_caps_central_directory_before_zipfile(
tmp_path, monkeypatch
):
zip_path = tmp_path / "large-directory.zip"
zip_path.write_bytes(
_legacy_zip_eocd(
entries=1,
central_directory_size=MAX_ZIP_CENTRAL_DIRECTORY_BYTES + 1,
)
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="central directory exceeds"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_total_uncompressed_size(tmp_path):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("one.txt", "123")
zf.writestr("two.txt", "456")
with pytest.raises(ValueError, match="maximum uncompressed size"):
safe_extract_zip(zip_path, tmp_path / "out", max_total_bytes=5)
def test_safe_extract_zip_wraps_bad_zip_file(tmp_path):
zip_path = tmp_path / "bad.zip"
zip_path.write_bytes(b"not a zip archive")
with pytest.raises(_CustomZipError, match="Invalid ZIP archive"):
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
def test_safe_extract_zip_wraps_unsupported_zip_version(tmp_path):
zip_path = tmp_path / "unsupported.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("file.txt", "contents")
archive = bytearray(zip_path.read_bytes())
central_header = archive.index(b"PK\x01\x02")
struct.pack_into("<H", archive, central_header + 6, 99)
zip_path.write_bytes(archive)
with pytest.raises(
_CustomZipError,
match="extractor version 4.5 or newer",
):
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
def test_read_zip_member_limited_returns_member_within_limit(tmp_path):
zip_path = tmp_path / "ok.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("extension.yml", "extension:\n id: demo\n")
with zipfile.ZipFile(zip_path, "r") as zf:
data = read_zip_member_limited(zf, "extension.yml")
assert data == b"extension:\n id: demo\n"
def test_read_zip_member_limited_does_not_retain_short_read_fragments():
response = _OneByteResponse(64)
archive = _FakeZipArchive(response, file_size=64)
assert (
read_zip_member_limited(archive, "extension.yml", max_bytes=64)
== b"x" * 64
)
assert response.peak_live <= 2
@pytest.mark.parametrize("value", [None, "1", 1.5, True])
def test_read_zip_member_limited_rejects_non_integer_limits(value):
archive = _FakeZipArchive(_OneByteResponse(0))
with pytest.raises(TypeError, match="integer"):
read_zip_member_limited(archive, "extension.yml", max_bytes=value)
def test_read_zip_member_limited_rejects_negative_limit_without_opening():
archive = _FakeZipArchive(_OneByteResponse(0))
with pytest.raises(ValueError, match="non-negative"):
read_zip_member_limited(archive, "extension.yml", max_bytes=-1)
def test_read_zip_member_limited_rejects_oversized_member(tmp_path):
zip_path = tmp_path / "bomb.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("extension.yml", "a" * 5000)
with zipfile.ZipFile(zip_path, "r") as zf:
with pytest.raises(ValueError, match="exceeds maximum size"):
read_zip_member_limited(zf, "extension.yml", max_bytes=16)
def test_read_zip_member_limited_rejects_when_declared_size_is_too_small():
archive = _FakeZipArchive(_OneByteResponse(5), file_size=1)
with pytest.raises(ValueError, match="exceeds maximum size"):
read_zip_member_limited(
archive,
"extension.yml",
max_bytes=4,
)
def test_read_zip_member_limited_escapes_control_characters_in_errors():
member_name = "bad\x1b[2J/extension.yml"
archive = _FakeZipArchive(
_OneByteResponse(0),
filename=member_name,
file_size=5,
)
with pytest.raises(ValueError) as exc_info:
read_zip_member_limited(
archive,
member_name,
max_bytes=4,
)
assert "\x1b" not in str(exc_info.value)
assert "\\x1b" in str(exc_info.value)
def test_read_zip_member_limited_wraps_missing_member(tmp_path):
zip_path = tmp_path / "ok.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("other.txt", "x")
with zipfile.ZipFile(zip_path, "r") as zf:
with pytest.raises(_CustomZipError, match="ZIP member not found"):
read_zip_member_limited(zf, "extension.yml", error_type=_CustomZipError)
def test_read_zip_member_limited_wraps_decompression_errors():
archive = _FakeZipArchive(_ExplodingResponse(), file_size=1)
with pytest.raises(_CustomZipError, match="Failed to read ZIP member"):
read_zip_member_limited(
archive,
"extension.yml",
error_type=_CustomZipError,
)
@pytest.mark.parametrize(
"members",
[
[("nested\\file.txt", "first"), ("nested/file.txt", "second")],
[("node", "file"), ("node/child.txt", "child")],
[("node/child.txt", "child"), ("node", "file")],
[("Readme.txt", "first"), ("README.TXT", "second")],
[("caf\u00e9.txt", "first"), ("cafe\u0301.txt", "second")],
],
)
def test_safe_extract_zip_rejects_conflicting_paths_before_writing(
tmp_path, members
):
zip_path = tmp_path / "conflict.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for name, contents in members:
zf.writestr(name, contents)
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Conflicting path"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
@pytest.mark.parametrize(
"member_name",
[
"file::$DATA",
"file.",
"file ",
" leading.txt",
"NUL.txt",
"COM\u00b9.log",
"COM1 .txt",
"CONOUT$.log",
"nested/name?.txt",
"nested/control\u0001.txt",
"nested/delete\u007f.txt",
"nested/csi\u009b[2J.txt",
],
)
def test_safe_extract_zip_rejects_nonportable_member_names(tmp_path, member_name):
zip_path = tmp_path / "nonportable.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "contents")
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Unsafe path"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
@pytest.mark.parametrize(
"member_name",
[
"a" * 256,
"a/" * 2048 + "file.txt",
],
)
def test_safe_extract_zip_rejects_excessively_long_paths(tmp_path, member_name):
zip_path = tmp_path / "nonportable.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "contents")
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Unsafe path"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
@pytest.mark.parametrize(
("control_character", "escaped_character"),
[
("\x1b", "\\x1b"),
("\x7f", "\\x7f"),
("\x9b", "\\x9b"),
],
)
def test_safe_extract_zip_escapes_unicode_control_characters_in_errors(
tmp_path,
control_character,
escaped_character,
):
zip_path = tmp_path / "terminal-control.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(f"bad{control_character}[2J.txt", "contents")
with pytest.raises(ValueError) as exc_info:
safe_extract_zip(zip_path, tmp_path / "out")
assert control_character not in str(exc_info.value)
assert escaped_character in str(exc_info.value)
def test_safe_extract_zip_accepts_single_decomposed_unicode_name(tmp_path):
zip_path = tmp_path / "unicode.zip"
out_dir = tmp_path / "out"
decomposed_name = "cafe\u0301.txt"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(decomposed_name, "contents")
safe_extract_zip(zip_path, out_dir)
assert (out_dir / decomposed_name).read_text(encoding="utf-8") == "contents"
def test_safe_extract_zip_wraps_decompression_errors(tmp_path, monkeypatch):
zip_path = tmp_path / "corrupt.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("extension.yml", "x")
archive = _FakeZipArchive(_ExplodingResponse(), file_size=1)
monkeypatch.setattr(zipfile, "ZipFile", lambda *_args, **_kwargs: archive)
with pytest.raises(_CustomZipError, match="Failed to extract ZIP member"):
safe_extract_zip(
zip_path,
tmp_path / "out",
error_type=_CustomZipError,
)
def test_safe_extract_zip_enforces_actual_member_size(tmp_path, monkeypatch):
zip_path = tmp_path / "lying-size.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("extension.yml", "x")
archive = _FakeZipArchive(_OneByteResponse(5), file_size=1)
monkeypatch.setattr(zipfile, "ZipFile", lambda *_args, **_kwargs: archive)
with pytest.raises(ValueError, match="exceeds maximum size"):
safe_extract_zip(
zip_path,
tmp_path / "out",
max_member_bytes=4,
)
def test_safe_extract_zip_extracts_safe_archive(tmp_path):
zip_path = tmp_path / "ok.zip"
out_dir = tmp_path / "out"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("nested/file.txt", "hello")
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "nested" / "file.txt").read_text(encoding="utf-8") == "hello"
def test_safe_extract_zip_treats_normalized_trailing_backslash_as_directory(tmp_path):
zip_path = tmp_path / "ok.zip"
out_dir = tmp_path / "out"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("nested\\", "")
zf.writestr("nested/file.txt", "hello")
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "nested").is_dir()
assert (out_dir / "nested" / "file.txt").read_text(encoding="utf-8") == "hello"