mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
* fix(archives): wrap the bare EOFError a truncated tar.gz raises `tarfile` wraps most decompression failures in `TarError`, but a gzip stream that ends before its end-of-stream marker escapes as a bare `EOFError` from the gzip layer. `EOFError` derives from neither `TarError` nor `OSError`, so it bypassed all three of the tar handlers added with tar archive support (#3874): - the format probe in `detect_archive_format`, which caught only `tarfile.TarError`; - `tarfile.open` in `safe_extract_tar`; - member iteration in `safe_extract_tar`. A truncated `.tar.gz` — an interrupted download, a partially written file — therefore raised a raw `EOFError` straight through the caller's `error_type`, so callers catching `ValueError`/`ExtensionError`/ `PresetError` never saw it. In `specify workflow add` the effect is worse than a traceback: Typer treats a bare `EOFError` as a Ctrl-D abort, so the command printed only "Aborted." with no diagnostic at all. The ZIP twin reports "Invalid workflow archive: Invalid ZIP archive: <path>". Route all three sites through a shared `_TAR_DECOMPRESSION_ERRORS` tuple so they stay in sync. `zlib.error` is included alongside `EOFError`: it is likewise neither a `TarError` nor an `OSError` and can surface from a corrupt deflate block. `OSError` is kept only on the two `safe_extract_tar` sites, which report genuine I/O failures; adding it to the probe would silently swallow them instead. Truncated tar.gz now reports the same clean, domain-typed error as the ZIP path. Tests cover both the short prefix that fails in `tarfile.open` and the longer ones that fail during member iteration — `tarfile` decompresses lazily, so the leak surfaced at different sites depending on how much of the stream survived. Assisted-by: Claude Opus 5 (1M context) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(archives): cover the bare zlib.error a corrupt deflate block raises Review feedback: the `zlib.error` arm of `_TAR_DECOMPRESSION_ERRORS` was not exercised. Every regression added with the fix truncates a valid deflate stream, which raises `EOFError`, so `zlib.error` could regress independently of the EOF handling. It is genuinely reachable, but only under a narrower condition than the truncation cases. `tarfile` converts `zlib.error` to `ReadError` while reading a member *header*, but the forward seek it performs to skip member *data* (`tarfile.next`) sits outside that conversion, so a corrupt region past the first header escapes raw. Reaching that seek needs members larger than the gzip read buffer: with small members the whole stream is decompressed during the first header read and the error is wrapped. The new fixture therefore uses two 256 KiB members at `compresslevel=1` — a ~7 KiB archive — corrupted past the midpoint so the first header still reads clean. Adds four tests: the two `safe_extract_tar` sites (plain and with a caller-supplied `error_type`), the `safe_extract_archive` entry point with a caller-supplied `error_type`, and a guard asserting the fixture still reaches the module as a bare `zlib.error` — so if a future Python wraps it, that fails loudly instead of the coverage silently decaying into a duplicate of the `EOFError` cases. Verified test-the-test: the three wrapping tests fail against the unmodified `_download_security.py` with a raw `zlib.error: Error -3 while decompressing data: invalid distance code`, and pass with the fix. Also corrects the scope claimed for the probe site. Fuzzing 2800 corrupt archives never produced a bare `zlib.error` from `tarfile.open` alone, because the only read it performs is the header read that `tarfile` already converts. The probe's `zlib.error` arm is defensive, not load-bearing; the tuple comment and a detection test now say so rather than implying coverage that cannot exist. Assisted-by: Claude Opus 5 (1M context) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(archives): make the corrupt-deflate fixture zlib-version independent CI failure on macos-latest/3.13: `test_corrupt_deflate_fixture_raises_bare_zlib_error` failed with `gzip.BadGzipFile: CRC check failed`. The other five pytest jobs were fail-fast cancellations, not real failures, and ruff was already green. The fixture built its corruption by XOR-ing 64 arbitrary bytes mid-stream. Whether that produces a *structural* deflate error is zlib-version dependent: on the macOS runner the mangled bytes still decoded, so the stream instead failed the trailing gzip CRC check and raised `BadGzipFile` -- an `OSError`, which the pre-fix `(TarError, OSError)` handler already caught. The guard test exists precisely to catch that degradation, and it did its job. Replaces the XOR with a deflate block header whose `BTYPE` is the reserved value `0b11`. Every zlib rejects that identically as "invalid block type", and it fails during decompression rather than at the CRC check, so no version can turn it into a `TarError` or `OSError`. The stream is assembled by hand (`compressobj(-15)` + explicit gzip header/trailer) so the invalid block lands a controlled 256 KiB into the first member's data -- past the gzip read buffer, so the first header still reads clean and the failure surfaces from the forward seek in `tarfile.next`, which is the site the raw `zlib.error` escapes from. A sweep over clean-prefix sizes confirms a wide margin: with 512 KiB members every prefix from 160 KiB up yields a bare `zlib.error`, versus the transition below ~131 KiB where `tarfile` still wraps it as `ReadError`. The hand-built gzip header also zeroes the mtime field, so the fixture is now byte-identical across builds instead of embedding a timestamp. Strengthens the guard to assert what the fix actually depends on -- that the exception is neither a `TarError` nor an `OSError` -- so the fixture cannot silently decay into an already-caught type again. Production code is unchanged from ef49acc; this is test-only. Verified test-the-test by dropping the `zlib.error` arm from `_TAR_DECOMPRESSION_ERRORS`: the three wrapping tests fail with the raw `zlib.error: Error -3 while decompressing data: invalid block type`, and pass with it restored. `tests/test_download_security.py`: 193 passed. `ruff check src tests` (the exact CI command): all checks passed. Assisted-by: Claude Opus 4.8 (1M context) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1276 lines
47 KiB
Python
1276 lines
47 KiB
Python
"""Helpers for bounded downloads and archive extraction."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import re
|
|
import socket
|
|
import stat
|
|
import struct
|
|
import tarfile
|
|
import unicodedata
|
|
import zipfile
|
|
import zlib
|
|
from collections.abc import Iterator
|
|
from contextlib import ExitStack, contextmanager
|
|
from ipaddress import IPv4Address, IPv6Address, ip_address
|
|
from itertools import pairwise
|
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
from typing import BinaryIO, Literal, NoReturn, TypeVar
|
|
from urllib.parse import ParseResult, urlparse
|
|
|
|
|
|
ErrorT = TypeVar("ErrorT", bound=Exception)
|
|
ArchiveFormat = Literal["zip", "tar.gz"]
|
|
|
|
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
|
|
MAX_ZIP_ENTRIES = 512
|
|
MAX_ZIP_MEMBER_BYTES = 10 * 1024 * 1024
|
|
MAX_ZIP_TOTAL_BYTES = 50 * 1024 * 1024
|
|
MAX_ZIP_PATH_BYTES = 4096
|
|
MAX_ZIP_COMPONENT_BYTES = 255
|
|
# ``ZipFile`` reads this whole structure into memory. Four MiB leaves roughly
|
|
# 8 KiB of filename/extra/comment metadata for each of the 512 allowed entries.
|
|
MAX_ZIP_CENTRAL_DIRECTORY_BYTES = 4 * 1024 * 1024
|
|
READ_CHUNK_SIZE = 64 * 1024
|
|
|
|
# Tighter ceilings for responses that are read fully into memory and parsed as
|
|
# JSON. The 50 MiB MAX_DOWNLOAD_BYTES default is sized for archive/payload
|
|
# downloads; JSON responses are far smaller, so capping them close to their real
|
|
# size shrinks the memory-DoS surface and keeps the "too large" error reachable
|
|
# (rather than only triggering on tens of MiB). Pass the matching constant
|
|
# explicitly at each JSON call site so the intended bound is pinned there.
|
|
# * METADATA - fixed-shape single-object responses (an OAuth token, one
|
|
# release's metadata): a few KiB in practice, 1 MiB is already generous.
|
|
# * CATALOG - listings that grow with the number of published items. The
|
|
# largest bundled catalog is ~130 KiB today, so 8 MiB leaves ~60x headroom
|
|
# for growth while staying well under the download ceiling.
|
|
MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
|
|
MAX_JSON_CATALOG_BYTES = 8 * 1024 * 1024
|
|
|
|
_WINDOWS_INVALID_FILENAME_CHARS = frozenset('<>:"|?*')
|
|
_WINDOWS_RESERVED_FILENAME = re.compile(
|
|
r"^(?:con|prn|aux|nul|conin\$|conout\$|"
|
|
r"com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])$",
|
|
re.IGNORECASE,
|
|
)
|
|
_ZIP_EOCD = struct.Struct("<4s4H2LH")
|
|
_ZIP_EOCD_SIGNATURE = b"PK\x05\x06"
|
|
_ZIP64_LOCATOR_SIGNATURE = b"PK\x06\x07"
|
|
_ZIP_CENTRAL_HEADER_SIZE = 46
|
|
_ZIP_CENTRAL_SIGNATURE = b"PK\x01\x02"
|
|
_ZIP_LOCAL_HEADER_SIZE = 30
|
|
_ZIP_LOCAL_SIGNATURE = b"PK\x03\x04"
|
|
_ZIP_EXTRA_HEADER = struct.Struct("<HH")
|
|
_ZIP64_EXTRA_FIELD_ID = 0x0001
|
|
_ZIP64_MIN_EXTRACT_VERSION = 45
|
|
_ZIP_UINT16_MAX = (1 << 16) - 1
|
|
_ZIP_UINT32_MAX = (1 << 32) - 1
|
|
_ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
|
|
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
|
|
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
|
|
)
|
|
#: Decompression failures a truncated or corrupt gzip stream raises from
|
|
#: ``tarfile``. Most are wrapped in ``TarError``, but two escape raw, and
|
|
#: neither derives from ``TarError`` or ``OSError``, so both bypass a
|
|
#: ``(TarError, OSError)`` handler:
|
|
#:
|
|
#: * ``EOFError`` -- from the gzip layer when the stream ends before its
|
|
#: end-of-stream marker, i.e. a truncated archive.
|
|
#: * ``zlib.error`` -- from a corrupt deflate block. ``tarfile`` converts this
|
|
#: to ``ReadError`` while reading a member *header*, but the forward seek it
|
|
#: performs to skip member *data* sits outside that conversion, so a corrupt
|
|
#: region past the first header escapes raw.
|
|
_TAR_DECOMPRESSION_ERRORS = (tarfile.TarError, EOFError, zlib.error)
|
|
|
|
_ARCHIVE_CONTENT_TYPES: dict[str, ArchiveFormat] = {
|
|
"application/gzip": "tar.gz",
|
|
"application/x-gzip": "tar.gz",
|
|
"application/x-tar+gzip": "tar.gz",
|
|
"application/zip": "zip",
|
|
"application/x-zip-compressed": "zip",
|
|
}
|
|
|
|
|
|
def archive_format_from_name(name: str) -> ArchiveFormat | None:
|
|
"""Return the supported archive format declared by a path or URL."""
|
|
try:
|
|
path = urlparse(name).path.lower()
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if path.endswith(".tar.gz") or path.endswith(".tgz"):
|
|
return "tar.gz"
|
|
if path.endswith(".zip"):
|
|
return "zip"
|
|
return None
|
|
|
|
|
|
def archive_format_from_content_type(content_type: str | None) -> ArchiveFormat | None:
|
|
"""Return the supported archive format declared by an HTTP Content-Type."""
|
|
if not isinstance(content_type, str):
|
|
return None
|
|
media_type = content_type.partition(";")[0].strip().lower()
|
|
return _ARCHIVE_CONTENT_TYPES.get(media_type)
|
|
|
|
|
|
def archive_suffix(archive_format: ArchiveFormat) -> str:
|
|
"""Return the canonical filename suffix for *archive_format*."""
|
|
if archive_format == "zip":
|
|
return ".zip"
|
|
if archive_format == "tar.gz":
|
|
return ".tar.gz"
|
|
raise ValueError(f"Unsupported archive format: {archive_format!r}")
|
|
|
|
|
|
def detect_archive_format(
|
|
archive_path: Path,
|
|
*,
|
|
archive_file: BinaryIO | None = None,
|
|
source_name: str | None = None,
|
|
content_type: str | None = None,
|
|
error_type: type[ErrorT] = ValueError,
|
|
) -> ArchiveFormat:
|
|
"""Validate the declared archive format against the file contents.
|
|
|
|
A recognized path/URL suffix is authoritative. For remote responses whose
|
|
final URL has no archive suffix, a recognized Content-Type may declare the
|
|
format instead. When both declarations are recognized they must agree, and
|
|
the resulting declaration must match the archive bytes.
|
|
"""
|
|
archive_path = Path(archive_path)
|
|
name_format = archive_format_from_name(
|
|
source_name if source_name is not None else str(archive_path)
|
|
)
|
|
content_format = archive_format_from_content_type(content_type)
|
|
if (
|
|
name_format is not None
|
|
and content_format is not None
|
|
and name_format != content_format
|
|
):
|
|
_raise(
|
|
error_type,
|
|
f"Archive format mismatch: filename declares {name_format} but "
|
|
f"Content-Type declares {content_format}",
|
|
)
|
|
declared_format = name_format or content_format
|
|
|
|
with ExitStack() as stack:
|
|
if archive_file is None:
|
|
try:
|
|
archive_file = stack.enter_context(archive_path.open("rb"))
|
|
except OSError as exc:
|
|
_raise_from(error_type, f"Invalid archive: {archive_path}", exc)
|
|
try:
|
|
archive_file.seek(0)
|
|
is_zip = zipfile.is_zipfile(archive_file)
|
|
archive_file.seek(0)
|
|
signature = archive_file.read(4)
|
|
# Let the bounded ZIP preflight report structural errors such as
|
|
# impossible entry counts. ``is_zipfile`` rejects those before the
|
|
# extractor can produce the established security diagnostic.
|
|
is_zip = is_zip or signature in {
|
|
b"PK\x03\x04",
|
|
b"PK\x05\x06",
|
|
b"PK\x07\x08",
|
|
}
|
|
is_gzip = signature[:2] == b"\x1f\x8b"
|
|
archive_file.seek(0)
|
|
is_tar_gz = False
|
|
if is_gzip:
|
|
try:
|
|
with tarfile.open(fileobj=archive_file, mode="r:gz"):
|
|
is_tar_gz = True
|
|
except _TAR_DECOMPRESSION_ERRORS:
|
|
# A truncated gzip stream raises a bare EOFError here rather
|
|
# than a TarError, so catching only TarError let it escape
|
|
# this probe as a raw exception instead of leaving
|
|
# ``is_tar_gz`` False and reporting the format mismatch.
|
|
pass
|
|
archive_file.seek(0)
|
|
except OSError as exc:
|
|
_raise_from(error_type, f"Invalid archive: {archive_path}", exc)
|
|
|
|
actual_format: ArchiveFormat | None
|
|
if is_zip and not is_tar_gz:
|
|
actual_format = "zip"
|
|
elif is_tar_gz and not is_zip:
|
|
actual_format = "tar.gz"
|
|
else:
|
|
actual_format = None
|
|
if declared_format is None:
|
|
if actual_format is None:
|
|
_raise(
|
|
error_type,
|
|
"Unsupported archive format; expected .zip, .tar.gz, or .tgz",
|
|
)
|
|
declared_format = actual_format
|
|
if actual_format != declared_format:
|
|
actual_label = actual_format or "invalid/unsupported data"
|
|
_raise(
|
|
error_type,
|
|
f"Archive format mismatch: expected {declared_format}, got {actual_label}",
|
|
)
|
|
return declared_format
|
|
|
|
|
|
def _ip_address_without_scope(
|
|
hostname: str,
|
|
) -> IPv4Address | IPv6Address | None:
|
|
"""Parse a canonical IP literal, validating an optional IPv6 zone ID."""
|
|
if "%" in hostname:
|
|
# Accept only the RFC 6874 ``%25<zone>`` spelling. Other escapes can
|
|
# alter the IPv6 address when urllib unquotes the authority.
|
|
address_text, separator, zone = hostname.partition("%25")
|
|
if (
|
|
not separator
|
|
or ":" not in address_text
|
|
or "%" in address_text
|
|
or "%" in zone
|
|
):
|
|
return None
|
|
if not zone or any(
|
|
not (character.isascii() and (character.isalnum() or character in "._~-"))
|
|
for character in zone
|
|
):
|
|
return None
|
|
else:
|
|
address_text = hostname
|
|
try:
|
|
address = ip_address(address_text)
|
|
except ValueError:
|
|
return None
|
|
if "%" in hostname and not isinstance(address, IPv6Address):
|
|
return None
|
|
return address
|
|
|
|
|
|
def _is_ip_loopback(address: IPv4Address | IPv6Address | None) -> bool:
|
|
if address is None:
|
|
return False
|
|
mapped = getattr(address, "ipv4_mapped", None)
|
|
return address.is_loopback or bool(mapped and mapped.is_loopback)
|
|
|
|
|
|
def _is_ip_local_redirect_target(
|
|
address: IPv4Address | IPv6Address | None,
|
|
) -> bool:
|
|
"""Treat loopback and unspecified listener aliases as local targets."""
|
|
if address is None:
|
|
return False
|
|
mapped = getattr(address, "ipv4_mapped", None)
|
|
return _is_ip_loopback(address) or address.is_unspecified or bool(
|
|
mapped and mapped.is_unspecified
|
|
)
|
|
|
|
|
|
def _parse_url(url: str) -> ParseResult | None:
|
|
"""Parse *url*, rejecting missing hosts and malformed ports."""
|
|
try:
|
|
parsed = urlparse(url)
|
|
hostname = parsed.hostname
|
|
# Accessing ``port`` performs urllib's range and syntax validation.
|
|
parsed.port
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if not hostname:
|
|
return None
|
|
|
|
if "%" in hostname:
|
|
# urllib unquotes reg-name/IPv4 authorities before connecting. Reject
|
|
# them so encoded dots, characters, ports, or brackets cannot make the
|
|
# validated hostname differ from the effective target. The only safe
|
|
# percent form retained is a validated bracketed IPv6 zone ID.
|
|
if _ip_address_without_scope(hostname) is None:
|
|
return None
|
|
elif ":" not in hostname:
|
|
try:
|
|
hostname.encode("idna")
|
|
except UnicodeError:
|
|
return None
|
|
return parsed
|
|
|
|
|
|
def _is_definite_loopback_host(hostname: str) -> bool:
|
|
"""Recognize only unambiguous hosts that may safely authorize HTTP."""
|
|
if not hostname.isascii():
|
|
return False
|
|
if hostname == "localhost":
|
|
return True
|
|
return _is_ip_loopback(_ip_address_without_scope(hostname))
|
|
|
|
|
|
def _is_potential_local_target_host(hostname: str) -> bool:
|
|
"""Conservatively classify aliases that could reach a local listener."""
|
|
if ":" in hostname:
|
|
return _is_ip_local_redirect_target(_ip_address_without_scope(hostname))
|
|
try:
|
|
host = hostname.encode("idna").decode("ascii").lower().removesuffix(".")
|
|
except UnicodeError:
|
|
return False
|
|
if host == "localhost" or host.endswith(".localhost"):
|
|
return True
|
|
|
|
address = _ip_address_without_scope(host)
|
|
if address is None:
|
|
# Historical IPv4 spellings are resolver-dependent. They are never
|
|
# trusted to authorize HTTP, but treating them as potentially local
|
|
# prevents them from bypassing a remote-to-loopback redirect check.
|
|
try:
|
|
address = ip_address(socket.inet_aton(host))
|
|
except OSError:
|
|
return False
|
|
return _is_ip_local_redirect_target(address)
|
|
|
|
|
|
def is_loopback_url(url: str) -> bool:
|
|
"""Return whether *url* has an unambiguous loopback host."""
|
|
parsed = _parse_url(url)
|
|
return parsed is not None and _is_definite_loopback_host(parsed.hostname)
|
|
|
|
|
|
def _is_potential_local_target_url(url: str) -> bool:
|
|
parsed = _parse_url(url)
|
|
return parsed is not None and _is_potential_local_target_host(parsed.hostname)
|
|
|
|
|
|
def is_https_or_localhost_http(url: str) -> bool:
|
|
"""Return True if *url* is HTTPS, or HTTP limited to loopback hosts.
|
|
|
|
Shared scheme-safety predicate used by the auth HTTP redirect handler and
|
|
direct URL validations in CLI download flows.
|
|
|
|
A hostname is always required: a URL without one (e.g. ``https:///x``)
|
|
has no real target and is rejected regardless of scheme.
|
|
|
|
The HTTP exception is deliberately limited to unambiguous ``localhost``
|
|
and canonical IPv4/IPv6 loopback literals. Ambiguous numeric, Unicode, and
|
|
unspecified-address aliases are classified defensively for redirects but
|
|
never authorize HTTP. No DNS lookup is performed; DNS and hosts-file
|
|
aliases require connection-level rebinding protection outside this helper.
|
|
"""
|
|
parsed = _parse_url(url)
|
|
if parsed is None:
|
|
return False
|
|
return parsed.scheme == "https" or (
|
|
parsed.scheme == "http" and _is_definite_loopback_host(parsed.hostname)
|
|
)
|
|
|
|
|
|
def is_safe_download_redirect(old_url: str, new_url: str) -> bool:
|
|
"""Return whether a redirect preserves the shared download URL policy."""
|
|
if not is_https_or_localhost_http(new_url):
|
|
return False
|
|
return not _is_potential_local_target_url(new_url) or is_loopback_url(old_url)
|
|
|
|
|
|
def _raise(error_type: type[ErrorT], message: str) -> NoReturn:
|
|
raise error_type(message)
|
|
|
|
|
|
def _raise_from(error_type: type[ErrorT], message: str, exc: Exception) -> NoReturn:
|
|
raise error_type(message) from exc
|
|
|
|
|
|
class _ReadLimitExceeded(Exception):
|
|
"""Internal signal used to keep domain-specific errors at call sites."""
|
|
|
|
|
|
def _validate_non_negative_int(value: int, name: str) -> None:
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise TypeError(f"{name} must be an integer")
|
|
if value < 0:
|
|
raise ValueError(f"{name} must be non-negative")
|
|
|
|
|
|
def _validate_max_bytes(max_bytes: int) -> None:
|
|
_validate_non_negative_int(max_bytes, "max_bytes")
|
|
|
|
|
|
def _read_limited(response, max_bytes: int) -> bytes:
|
|
"""Read a stream with bounded requests and without retaining fragments."""
|
|
output = io.BytesIO()
|
|
total = 0
|
|
limit = max_bytes + 1
|
|
while total < limit:
|
|
chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > max_bytes:
|
|
raise _ReadLimitExceeded
|
|
output.write(chunk)
|
|
return output.getvalue()
|
|
|
|
|
|
def read_response_limited(
|
|
response,
|
|
*,
|
|
max_bytes: int = MAX_DOWNLOAD_BYTES,
|
|
error_type: type[ErrorT] = ValueError,
|
|
label: str = "download",
|
|
) -> bytes:
|
|
"""Read at most *max_bytes* from a response object.
|
|
|
|
``response.read(n)`` is only guaranteed to return *up to* ``n`` bytes and may
|
|
return fewer even when more data is pending (e.g. chunked transfer encoding),
|
|
so a single ``read(max_bytes + 1)`` cannot enforce the bound on its own. Read
|
|
in a loop until EOF or until one byte past the limit has been accumulated.
|
|
|
|
*max_bytes* is keyword-only. It defaults to the module-wide
|
|
``MAX_DOWNLOAD_BYTES`` (50 MiB) ceiling for archive/payload downloads;
|
|
callers with a tighter budget (e.g. small JSON responses) should pass an
|
|
explicit value so the intended bound is pinned at the call site rather than
|
|
tracking changes to the shared default.
|
|
"""
|
|
_validate_max_bytes(max_bytes)
|
|
try:
|
|
return _read_limited(response, max_bytes)
|
|
except _ReadLimitExceeded:
|
|
_raise(error_type, f"{label!r} exceeds maximum size of {max_bytes} bytes")
|
|
|
|
|
|
def build_safe_download_path(
|
|
target_dir: Path,
|
|
identifier: object,
|
|
version: object,
|
|
*,
|
|
error_type: type[ErrorT] = ValueError,
|
|
label: str = "archive",
|
|
suffix: str = ".zip",
|
|
) -> Path:
|
|
"""Build a portable single-component archive path inside *target_dir*."""
|
|
if not isinstance(identifier, str) or not isinstance(version, str):
|
|
_raise(
|
|
error_type,
|
|
f"Unsafe {label} download filename derived from "
|
|
f"{identifier!r} and {version!r}",
|
|
)
|
|
|
|
if suffix not in {".zip", ".tar.gz", ".tgz"}:
|
|
_raise(error_type, f"Unsupported archive download suffix: {suffix!r}")
|
|
filename = f"{identifier}-{version}{suffix}"
|
|
try:
|
|
filename_too_long = (
|
|
len(filename.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
|
|
)
|
|
except UnicodeEncodeError:
|
|
filename_too_long = True
|
|
posix_path = PurePosixPath(filename)
|
|
windows_path = PureWindowsPath(filename)
|
|
if (
|
|
filename_too_long
|
|
or posix_path.name != filename
|
|
or windows_path.name != filename
|
|
or any(unicodedata.category(character) == "Cc" for character in filename)
|
|
or any(
|
|
character in _WINDOWS_INVALID_FILENAME_CHARS
|
|
for character in filename
|
|
)
|
|
or filename.endswith((" ", "."))
|
|
):
|
|
_raise(
|
|
error_type,
|
|
f"Unsafe {label} download filename derived from "
|
|
f"{identifier!r} and {version!r}",
|
|
)
|
|
return Path(target_dir) / filename
|
|
|
|
|
|
def read_zip_member_limited(
|
|
zf: zipfile.ZipFile,
|
|
name: str,
|
|
*,
|
|
max_bytes: int = MAX_ZIP_MEMBER_BYTES,
|
|
error_type: type[ErrorT] = ValueError,
|
|
label: str | None = None,
|
|
) -> bytes:
|
|
"""Read a single ZIP member into memory under a hard size cap.
|
|
|
|
Reading a member with ``zf.open(name).read()`` is unbounded: a crafted
|
|
archive can declare a tiny ``file_size`` yet decompress to many gigabytes (a
|
|
"zip bomb"), exhausting memory before the caller ever inspects the data.
|
|
This rejects members whose *declared* size already exceeds *max_bytes* and,
|
|
to defend against headers that lie, also reads in bounded chunks and stops
|
|
one byte past the limit.
|
|
|
|
Use this for any inline manifest/metadata read that happens *before*
|
|
:func:`safe_extract_zip` (which already enforces the same per-member bound
|
|
during extraction); a raw ``zf.open(...).read()`` bypasses that protection.
|
|
"""
|
|
_validate_max_bytes(max_bytes)
|
|
member_label = label or name
|
|
try:
|
|
info = zf.getinfo(name)
|
|
except KeyError as exc:
|
|
_raise_from(error_type, f"ZIP member not found: {name!r}", exc)
|
|
if info.file_size > max_bytes:
|
|
_raise(
|
|
error_type,
|
|
f"ZIP member {member_label!r} exceeds maximum size of {max_bytes} bytes",
|
|
)
|
|
|
|
try:
|
|
with zf.open(name, "r") as source:
|
|
return _read_limited(source, max_bytes)
|
|
except _ReadLimitExceeded:
|
|
_raise(
|
|
error_type,
|
|
f"ZIP member {member_label!r} exceeds maximum size of {max_bytes} bytes",
|
|
)
|
|
except Exception as exc:
|
|
_raise_from(
|
|
error_type,
|
|
f"Failed to read ZIP member {member_label!r}: {exc!r}",
|
|
exc,
|
|
)
|
|
|
|
|
|
def normalize_archive_member_name(
|
|
name: str,
|
|
*,
|
|
archive_label: str = "archive",
|
|
error_type: type[ErrorT] = ValueError,
|
|
) -> str:
|
|
"""Return a normalized, portable archive member name or raise if unsafe."""
|
|
if "\x00" in name:
|
|
_raise(error_type, f"Unsafe path in {archive_label} archive: {name!r}")
|
|
|
|
normalized = name.replace("\\", "/")
|
|
try:
|
|
encoded_name = normalized.encode("utf-8")
|
|
except UnicodeEncodeError:
|
|
_raise(error_type, f"Unsafe path in {archive_label} archive: {name!r}")
|
|
if len(encoded_name) > MAX_ZIP_PATH_BYTES:
|
|
_raise(
|
|
error_type,
|
|
f"Unsafe path in {archive_label} archive: {name!r} "
|
|
"(not portable across supported filesystems)",
|
|
)
|
|
path = PurePosixPath(normalized)
|
|
raw_parts = normalized.split("/")
|
|
# Strip a single trailing empty segment, i.e. the one-slash directory
|
|
# marker that legitimate ZIPs use ("mydir/", "mydir/subdir/"). Anything
|
|
# else that produces an empty segment - consecutive slashes ("a//b") or a
|
|
# second trailing slash - is left in place and rejected below as malformed.
|
|
if raw_parts and raw_parts[-1] == "":
|
|
raw_parts = raw_parts[:-1]
|
|
has_windows_drive = re.match(r"^[A-Za-z]:", normalized) is not None
|
|
if (
|
|
not raw_parts
|
|
or path.is_absolute()
|
|
or has_windows_drive
|
|
or any(part in {"", ".", ".."} for part in raw_parts)
|
|
):
|
|
_raise(
|
|
error_type,
|
|
f"Unsafe path in {archive_label} archive: {name!r} "
|
|
"(potential path traversal)",
|
|
)
|
|
for part in raw_parts:
|
|
reserved_stem = part.partition(".")[0].partition(":")[0].rstrip(" ")
|
|
if (
|
|
len(part.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
|
|
or any(
|
|
unicodedata.category(character) == "Cc"
|
|
for character in part
|
|
)
|
|
or any(character in _WINDOWS_INVALID_FILENAME_CHARS for character in part)
|
|
or part.startswith(" ")
|
|
or part.endswith((" ", "."))
|
|
or _WINDOWS_RESERVED_FILENAME.fullmatch(reserved_stem)
|
|
):
|
|
_raise(
|
|
error_type,
|
|
f"Unsafe path in {archive_label} archive: {name!r} "
|
|
"(not portable across supported filesystems)",
|
|
)
|
|
return normalized
|
|
|
|
|
|
def normalize_zip_member_name(
|
|
name: str,
|
|
*,
|
|
error_type: type[ErrorT] = ValueError,
|
|
) -> str:
|
|
"""Return a normalized, portable ZIP member name or raise if unsafe."""
|
|
return normalize_archive_member_name(
|
|
name,
|
|
archive_label="ZIP",
|
|
error_type=error_type,
|
|
)
|
|
|
|
|
|
def portable_archive_path_key(name: str) -> tuple[str, ...]:
|
|
"""Return a comparison key for filesystems with case/Unicode folding."""
|
|
normalized_name = name.replace("\\", "/")
|
|
return tuple(
|
|
unicodedata.normalize("NFC", part.casefold())
|
|
for part in normalized_name.removesuffix("/").split("/")
|
|
)
|
|
|
|
|
|
def portable_zip_path_key(name: str) -> tuple[str, ...]:
|
|
"""Backward-compatible ZIP-specific alias for portable archive keys."""
|
|
return portable_archive_path_key(name)
|
|
|
|
|
|
def _raise_zip64(error_type: type[ErrorT]) -> NoReturn:
|
|
_raise(
|
|
error_type,
|
|
"ZIP64 archives are not supported by the bounded extractor",
|
|
)
|
|
|
|
|
|
def _preflight_zip_entry_features(
|
|
extract_version: int,
|
|
compression_method: int,
|
|
*,
|
|
error_type: type[ErrorT],
|
|
) -> None:
|
|
"""Enforce the formats whose output can be bounded by ``ZipExtFile``.
|
|
|
|
Python's BZIP2 and LZMA ``ZipExtFile`` paths do not pass the requested
|
|
output length to the decompressor; only STORED and DEFLATED preserve this
|
|
module's hard memory bound. APPNOTE assigns extract version 4.5 to ZIP64
|
|
size extensions. Because this field declares the minimum extractor feature
|
|
level, reject 4.5 and every newer level for the supported methods,
|
|
independently of the usual size sentinels and extra field.
|
|
"""
|
|
if compression_method not in _BOUNDED_ZIP_COMPRESSION_METHODS:
|
|
_raise(
|
|
error_type,
|
|
f"Unsupported ZIP compression method {compression_method}; "
|
|
"the bounded extractor supports only STORED and DEFLATED",
|
|
)
|
|
if extract_version >= _ZIP64_MIN_EXTRACT_VERSION:
|
|
_raise(
|
|
error_type,
|
|
"ZIP64 or newer ZIP features requiring extractor version 4.5 or "
|
|
"newer are not supported by the bounded extractor",
|
|
)
|
|
|
|
|
|
def _reject_zip64_extra_fields(
|
|
extra: bytes,
|
|
zip_path: Path,
|
|
*,
|
|
error_type: type[ErrorT],
|
|
) -> None:
|
|
"""Reject ZIP64 extra fields and malformed complete extra records."""
|
|
offset = 0
|
|
while offset + _ZIP_EXTRA_HEADER.size <= len(extra):
|
|
field_id, field_size = _ZIP_EXTRA_HEADER.unpack_from(extra, offset)
|
|
field_end = offset + _ZIP_EXTRA_HEADER.size + field_size
|
|
if field_id == _ZIP64_EXTRA_FIELD_ID:
|
|
_raise_zip64(error_type)
|
|
if field_end > len(extra):
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
offset = field_end
|
|
|
|
|
|
def _preflight_zip_local_header(
|
|
archive_file,
|
|
zip_path: Path,
|
|
*,
|
|
error_type: type[ErrorT],
|
|
archive_prefix_size: int,
|
|
central_directory_start: int,
|
|
local_header_offset: int,
|
|
) -> None:
|
|
"""Reject local-entry ZIP64 indicators before ``ZipFile`` is constructed."""
|
|
physical_offset = archive_prefix_size + local_header_offset
|
|
if (
|
|
physical_offset < archive_prefix_size
|
|
or physical_offset + _ZIP_LOCAL_HEADER_SIZE > central_directory_start
|
|
):
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
|
|
archive_file.seek(physical_offset)
|
|
header = archive_file.read(_ZIP_LOCAL_HEADER_SIZE)
|
|
if (
|
|
len(header) != _ZIP_LOCAL_HEADER_SIZE
|
|
or header[:4] != _ZIP_LOCAL_SIGNATURE
|
|
):
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
|
|
extract_version = struct.unpack_from("<H", header, 4)[0]
|
|
compression_method = struct.unpack_from("<H", header, 8)[0]
|
|
_preflight_zip_entry_features(
|
|
extract_version,
|
|
compression_method,
|
|
error_type=error_type,
|
|
)
|
|
|
|
compressed_size, uncompressed_size = struct.unpack_from("<LL", header, 18)
|
|
if (
|
|
compressed_size == _ZIP_UINT32_MAX
|
|
or uncompressed_size == _ZIP_UINT32_MAX
|
|
):
|
|
_raise_zip64(error_type)
|
|
|
|
filename_size, extra_size = struct.unpack_from("<HH", header, 26)
|
|
extra_offset = physical_offset + _ZIP_LOCAL_HEADER_SIZE + filename_size
|
|
if extra_offset + extra_size > central_directory_start:
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
|
|
archive_file.seek(extra_offset)
|
|
extra = archive_file.read(extra_size)
|
|
if len(extra) != extra_size:
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
_reject_zip64_extra_fields(extra, zip_path, error_type=error_type)
|
|
|
|
|
|
def _preflight_zip_central_directory(
|
|
archive_file,
|
|
zip_path: Path,
|
|
*,
|
|
error_type: type[ErrorT],
|
|
max_entries: int,
|
|
) -> None:
|
|
"""Bound and count the central directory before ``ZipFile`` materializes it."""
|
|
archive_file.seek(0, 2)
|
|
file_size = archive_file.tell()
|
|
tail_size = min(file_size, _ZIP_EOCD.size + _ZIP_MAX_COMMENT_BYTES)
|
|
archive_file.seek(file_size - tail_size)
|
|
tail = archive_file.read(tail_size)
|
|
|
|
# ZipFile selects the last EOCD signature in the search window. Inspect
|
|
# exactly that record too: falling back to an earlier signature would let
|
|
# the preflight validate one central directory while ZipFile materializes
|
|
# another.
|
|
eocd_index = tail.rfind(_ZIP_EOCD_SIGNATURE)
|
|
if eocd_index < 0 or eocd_index + _ZIP_EOCD.size > len(tail):
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
eocd = _ZIP_EOCD.unpack_from(tail, eocd_index)
|
|
comment_size = eocd[-1]
|
|
if eocd_index + _ZIP_EOCD.size + comment_size != len(tail):
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
|
|
eocd_offset = file_size - len(tail) + eocd_index
|
|
if eocd_offset >= 20:
|
|
archive_file.seek(eocd_offset - 20)
|
|
if archive_file.read(4) == _ZIP64_LOCATOR_SIGNATURE:
|
|
_raise_zip64(error_type)
|
|
|
|
(
|
|
_signature,
|
|
disk_number,
|
|
central_directory_disk,
|
|
entries_on_disk,
|
|
declared_entries,
|
|
central_directory_size,
|
|
central_directory_offset,
|
|
_comment_size,
|
|
) = eocd
|
|
if (
|
|
disk_number != 0
|
|
or central_directory_disk != 0
|
|
or entries_on_disk != declared_entries
|
|
):
|
|
_raise(error_type, "Multi-disk ZIP archives are not supported")
|
|
if (
|
|
declared_entries == _ZIP_UINT16_MAX
|
|
or central_directory_size == _ZIP_UINT32_MAX
|
|
or central_directory_offset == _ZIP_UINT32_MAX
|
|
):
|
|
_raise_zip64(error_type)
|
|
if declared_entries > max_entries:
|
|
_raise(
|
|
error_type,
|
|
f"ZIP archive contains too many entries "
|
|
f"({declared_entries} > {max_entries})",
|
|
)
|
|
if central_directory_size > MAX_ZIP_CENTRAL_DIRECTORY_BYTES:
|
|
_raise(
|
|
error_type,
|
|
f"ZIP central directory exceeds maximum size of "
|
|
f"{MAX_ZIP_CENTRAL_DIRECTORY_BYTES} bytes",
|
|
)
|
|
|
|
central_directory_start = eocd_offset - central_directory_size
|
|
if (
|
|
central_directory_start < 0
|
|
or central_directory_offset > central_directory_start
|
|
):
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
archive_prefix_size = central_directory_start - central_directory_offset
|
|
|
|
consumed = 0
|
|
actual_entries = 0
|
|
local_header_offsets: list[int] = []
|
|
while consumed < central_directory_size:
|
|
archive_file.seek(central_directory_start + consumed)
|
|
remaining = central_directory_size - consumed
|
|
if remaining < _ZIP_CENTRAL_HEADER_SIZE:
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
header = archive_file.read(_ZIP_CENTRAL_HEADER_SIZE)
|
|
if (
|
|
len(header) != _ZIP_CENTRAL_HEADER_SIZE
|
|
or header[:4] != _ZIP_CENTRAL_SIGNATURE
|
|
):
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
|
|
extract_version = struct.unpack_from("<H", header, 6)[0]
|
|
compression_method = struct.unpack_from("<H", header, 10)[0]
|
|
_preflight_zip_entry_features(
|
|
extract_version,
|
|
compression_method,
|
|
error_type=error_type,
|
|
)
|
|
|
|
compressed_size, uncompressed_size = struct.unpack_from("<LL", header, 20)
|
|
disk_number_start = struct.unpack_from("<H", header, 34)[0]
|
|
local_header_offset = struct.unpack_from("<L", header, 42)[0]
|
|
if (
|
|
compressed_size == _ZIP_UINT32_MAX
|
|
or uncompressed_size == _ZIP_UINT32_MAX
|
|
or local_header_offset == _ZIP_UINT32_MAX
|
|
or disk_number_start == _ZIP_UINT16_MAX
|
|
):
|
|
_raise_zip64(error_type)
|
|
if disk_number_start != 0:
|
|
_raise(error_type, "Multi-disk ZIP archives are not supported")
|
|
|
|
filename_size, extra_size, comment_size = struct.unpack_from(
|
|
"<HHH", header, 28
|
|
)
|
|
variable_size = filename_size + extra_size + comment_size
|
|
record_size = _ZIP_CENTRAL_HEADER_SIZE + variable_size
|
|
if record_size > remaining:
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
variable_data = archive_file.read(variable_size)
|
|
if len(variable_data) != variable_size:
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
extra = variable_data[filename_size : filename_size + extra_size]
|
|
_reject_zip64_extra_fields(extra, zip_path, error_type=error_type)
|
|
local_header_offsets.append(local_header_offset)
|
|
|
|
consumed += record_size
|
|
actual_entries += 1
|
|
if actual_entries > max_entries:
|
|
_raise(
|
|
error_type,
|
|
f"ZIP archive contains too many entries "
|
|
f"({actual_entries} > {max_entries})",
|
|
)
|
|
|
|
if actual_entries != declared_entries:
|
|
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
|
|
|
for local_header_offset in local_header_offsets:
|
|
_preflight_zip_local_header(
|
|
archive_file,
|
|
zip_path,
|
|
error_type=error_type,
|
|
archive_prefix_size=archive_prefix_size,
|
|
central_directory_start=central_directory_start,
|
|
local_header_offset=local_header_offset,
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def open_zip_bounded(
|
|
zip_path: Path,
|
|
*,
|
|
archive_file: BinaryIO | None = None,
|
|
error_type: type[ErrorT] = ValueError,
|
|
max_entries: int = MAX_ZIP_ENTRIES,
|
|
) -> Iterator[zipfile.ZipFile]:
|
|
"""Open an untrusted ZIP after a bounded-memory header preflight."""
|
|
_validate_non_negative_int(max_entries, "max_entries")
|
|
zip_path = Path(zip_path)
|
|
with ExitStack() as stack:
|
|
if archive_file is None:
|
|
try:
|
|
archive_file = stack.enter_context(zip_path.open("rb"))
|
|
except OSError as exc:
|
|
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
|
|
try:
|
|
_preflight_zip_central_directory(
|
|
archive_file,
|
|
zip_path,
|
|
error_type=error_type,
|
|
max_entries=max_entries,
|
|
)
|
|
except OSError as exc:
|
|
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
|
|
try:
|
|
archive_file.seek(0)
|
|
zf = stack.enter_context(zipfile.ZipFile(archive_file, "r"))
|
|
except Exception as exc:
|
|
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
|
|
yield zf
|
|
|
|
|
|
def safe_extract_zip(
|
|
zip_path: Path,
|
|
target_dir: Path,
|
|
*,
|
|
archive_file: BinaryIO | None = None,
|
|
error_type: type[ErrorT] = ValueError,
|
|
max_entries: int = MAX_ZIP_ENTRIES,
|
|
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
|
|
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
|
|
) -> None:
|
|
"""Extract a ZIP archive after path, symlink, and size validation."""
|
|
_validate_non_negative_int(max_member_bytes, "max_member_bytes")
|
|
_validate_non_negative_int(max_total_bytes, "max_total_bytes")
|
|
try:
|
|
target_root = target_dir.resolve()
|
|
except OSError as exc:
|
|
_raise_from(error_type, f"Invalid ZIP extraction target: {target_dir}", exc)
|
|
|
|
with open_zip_bounded(
|
|
zip_path,
|
|
archive_file=archive_file,
|
|
error_type=error_type,
|
|
max_entries=max_entries,
|
|
) as zf:
|
|
try:
|
|
members = zf.infolist()
|
|
except zipfile.BadZipFile as exc:
|
|
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
|
|
if len(members) > max_entries:
|
|
_raise(
|
|
error_type,
|
|
f"ZIP archive contains too many entries ({len(members)} > {max_entries})",
|
|
)
|
|
|
|
normalized_members: list[tuple[zipfile.ZipInfo, str, bool]] = []
|
|
validated_paths: dict[tuple[str, ...], tuple[str, bool]] = {}
|
|
total_size = 0
|
|
for member in members:
|
|
normalized_name = normalize_zip_member_name(
|
|
member.filename,
|
|
error_type=error_type,
|
|
)
|
|
is_dir = member.is_dir() or normalized_name.endswith("/")
|
|
path_key = portable_archive_path_key(normalized_name)
|
|
|
|
existing = validated_paths.get(path_key)
|
|
if existing is not None:
|
|
_raise(
|
|
error_type,
|
|
f"Conflicting path in ZIP archive: {member.filename} conflicts "
|
|
f"with {existing[0]}",
|
|
)
|
|
validated_paths[path_key] = (member.filename, is_dir)
|
|
|
|
mode = member.external_attr >> 16
|
|
if stat.S_ISLNK(mode):
|
|
_raise(error_type, f"Unsafe symlink in ZIP archive: {member.filename}")
|
|
|
|
member_path = (target_dir / normalized_name).resolve()
|
|
try:
|
|
member_path.relative_to(target_root)
|
|
except ValueError:
|
|
_raise(
|
|
error_type,
|
|
f"Unsafe path in ZIP archive: {member.filename} "
|
|
"(potential path traversal)",
|
|
)
|
|
|
|
if not is_dir:
|
|
if member.file_size > max_member_bytes:
|
|
_raise(
|
|
error_type,
|
|
f"ZIP member {member.filename} exceeds maximum size "
|
|
f"of {max_member_bytes} bytes",
|
|
)
|
|
total_size += member.file_size
|
|
if total_size > max_total_bytes:
|
|
_raise(
|
|
error_type,
|
|
f"ZIP archive exceeds maximum uncompressed size "
|
|
f"of {max_total_bytes} bytes",
|
|
)
|
|
|
|
normalized_members.append((member, normalized_name, is_dir))
|
|
|
|
# Tuple sorting places every path immediately before its descendants.
|
|
# One adjacent comparison per entry detects file/directory conflicts
|
|
# without repeatedly rebuilding every path prefix.
|
|
for (
|
|
(path_key, (original, is_dir)),
|
|
(next_key, (next_original, _next_is_dir)),
|
|
) in pairwise(sorted(validated_paths.items())):
|
|
if (
|
|
not is_dir
|
|
and len(next_key) > len(path_key)
|
|
and next_key[: len(path_key)] == path_key
|
|
):
|
|
_raise(
|
|
error_type,
|
|
f"Conflicting path in ZIP archive: {original} conflicts "
|
|
f"with {next_original}",
|
|
)
|
|
|
|
# The loop above bounds the *declared* total via member.file_size, but a
|
|
# crafted archive can understate those headers. Mirror the per-member
|
|
# guard below with a cumulative count of the bytes actually written so
|
|
# the total-size bound holds even when the headers lie.
|
|
total_written = 0
|
|
for member, normalized_name, is_dir in normalized_members:
|
|
member_path = target_dir / normalized_name
|
|
if is_dir:
|
|
try:
|
|
member_path.mkdir(parents=True, exist_ok=True)
|
|
except OSError as exc:
|
|
_raise_from(
|
|
error_type,
|
|
f"Failed to create ZIP directory {member.filename}: {exc}",
|
|
exc,
|
|
)
|
|
continue
|
|
|
|
try:
|
|
member_path.parent.mkdir(parents=True, exist_ok=True)
|
|
except OSError as exc:
|
|
_raise_from(
|
|
error_type,
|
|
f"Failed to create parent directory for ZIP member {member.filename}: {exc}",
|
|
exc,
|
|
)
|
|
written = 0
|
|
# Raised outside the try below: if error_type subclasses OSError or
|
|
# RuntimeError, raising inside would re-wrap the limit error as
|
|
# "Failed to extract" and lose the size-bound message.
|
|
limit_error: str | None = None
|
|
try:
|
|
with zf.open(member, "r") as source, member_path.open("wb") as dest:
|
|
while True:
|
|
chunk = source.read(READ_CHUNK_SIZE)
|
|
if not chunk:
|
|
break
|
|
written += len(chunk)
|
|
if written > max_member_bytes:
|
|
limit_error = (
|
|
f"ZIP member {member.filename} exceeds maximum size "
|
|
f"of {max_member_bytes} bytes"
|
|
)
|
|
break
|
|
total_written += len(chunk)
|
|
if total_written > max_total_bytes:
|
|
limit_error = (
|
|
f"ZIP archive exceeds maximum uncompressed size "
|
|
f"of {max_total_bytes} bytes"
|
|
)
|
|
break
|
|
dest.write(chunk)
|
|
except Exception as exc:
|
|
_raise_from(
|
|
error_type,
|
|
f"Failed to extract ZIP member {member.filename}: {exc}",
|
|
exc,
|
|
)
|
|
if limit_error is not None:
|
|
_raise(error_type, limit_error)
|
|
|
|
|
|
def safe_extract_tar(
|
|
archive_path: Path,
|
|
target_dir: Path,
|
|
*,
|
|
archive_file: BinaryIO | None = None,
|
|
error_type: type[ErrorT] = ValueError,
|
|
max_entries: int = MAX_ZIP_ENTRIES,
|
|
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
|
|
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
|
|
) -> None:
|
|
"""Extract a gzip-compressed tar after ZIP-equivalent safety validation."""
|
|
_validate_non_negative_int(max_entries, "max_entries")
|
|
_validate_non_negative_int(max_member_bytes, "max_member_bytes")
|
|
_validate_non_negative_int(max_total_bytes, "max_total_bytes")
|
|
archive_path = Path(archive_path)
|
|
try:
|
|
target_root = target_dir.resolve()
|
|
except OSError as exc:
|
|
_raise_from(error_type, f"Invalid tar extraction target: {target_dir}", exc)
|
|
|
|
try:
|
|
if archive_file is not None:
|
|
archive_file.seek(0)
|
|
archive = tarfile.open(
|
|
archive_path if archive_file is None else None,
|
|
mode="r:gz",
|
|
fileobj=archive_file,
|
|
)
|
|
except (*_TAR_DECOMPRESSION_ERRORS, OSError) as exc:
|
|
_raise_from(error_type, f"Invalid tar.gz archive: {archive_path}", exc)
|
|
|
|
with archive:
|
|
validated: list[tuple[tarfile.TarInfo, str, bool]] = []
|
|
validated_paths: dict[tuple[str, ...], tuple[str, bool]] = {}
|
|
total_size = 0
|
|
try:
|
|
for index, member in enumerate(archive, start=1):
|
|
if index > max_entries:
|
|
_raise(
|
|
error_type,
|
|
f"tar.gz archive contains too many entries "
|
|
f"({index} > {max_entries})",
|
|
)
|
|
normalized_name = normalize_archive_member_name(
|
|
member.name,
|
|
archive_label="tar.gz",
|
|
error_type=error_type,
|
|
)
|
|
is_dir = member.isdir()
|
|
if member.issym():
|
|
_raise(
|
|
error_type,
|
|
f"Unsafe symlink in tar.gz archive: {member.name}",
|
|
)
|
|
if member.islnk():
|
|
_raise(
|
|
error_type,
|
|
f"Unsafe hard link in tar.gz archive: {member.name}",
|
|
)
|
|
if not is_dir and not member.isreg():
|
|
_raise(
|
|
error_type,
|
|
f"Unsafe member type in tar.gz archive: {member.name}",
|
|
)
|
|
|
|
path_key = portable_archive_path_key(normalized_name)
|
|
existing = validated_paths.get(path_key)
|
|
if existing is not None:
|
|
_raise(
|
|
error_type,
|
|
f"Conflicting path in tar.gz archive: {member.name} "
|
|
f"conflicts with {existing[0]}",
|
|
)
|
|
validated_paths[path_key] = (member.name, is_dir)
|
|
|
|
member_path = (target_dir / normalized_name).resolve()
|
|
try:
|
|
member_path.relative_to(target_root)
|
|
except ValueError:
|
|
_raise(
|
|
error_type,
|
|
f"Unsafe path in tar.gz archive: {member.name} "
|
|
"(potential path traversal)",
|
|
)
|
|
|
|
if not is_dir:
|
|
if member.size > max_member_bytes:
|
|
_raise(
|
|
error_type,
|
|
f"tar.gz member {member.name} exceeds maximum size "
|
|
f"of {max_member_bytes} bytes",
|
|
)
|
|
total_size += member.size
|
|
if total_size > max_total_bytes:
|
|
_raise(
|
|
error_type,
|
|
f"tar.gz archive exceeds maximum uncompressed size "
|
|
f"of {max_total_bytes} bytes",
|
|
)
|
|
validated.append((member, normalized_name, is_dir))
|
|
except (*_TAR_DECOMPRESSION_ERRORS, OSError) as exc:
|
|
_raise_from(
|
|
error_type,
|
|
f"Invalid tar.gz archive: {archive_path}",
|
|
exc,
|
|
)
|
|
|
|
for (
|
|
(path_key, (original, is_dir)),
|
|
(next_key, (next_original, _next_is_dir)),
|
|
) in pairwise(sorted(validated_paths.items())):
|
|
if (
|
|
not is_dir
|
|
and len(next_key) > len(path_key)
|
|
and next_key[: len(path_key)] == path_key
|
|
):
|
|
_raise(
|
|
error_type,
|
|
f"Conflicting path in tar.gz archive: {original} conflicts "
|
|
f"with {next_original}",
|
|
)
|
|
|
|
total_written = 0
|
|
for member, normalized_name, is_dir in validated:
|
|
member_path = target_dir / normalized_name
|
|
if is_dir:
|
|
try:
|
|
member_path.mkdir(parents=True, exist_ok=True)
|
|
except OSError as exc:
|
|
_raise_from(
|
|
error_type,
|
|
f"Failed to create tar.gz directory {member.name}: {exc}",
|
|
exc,
|
|
)
|
|
continue
|
|
try:
|
|
member_path.parent.mkdir(parents=True, exist_ok=True)
|
|
source = archive.extractfile(member)
|
|
if source is None:
|
|
_raise(
|
|
error_type,
|
|
f"Failed to read tar.gz member {member.name}",
|
|
)
|
|
written = 0
|
|
limit_error: str | None = None
|
|
with source, member_path.open("wb") as dest:
|
|
while True:
|
|
chunk = source.read(READ_CHUNK_SIZE)
|
|
if not chunk:
|
|
break
|
|
written += len(chunk)
|
|
if written > max_member_bytes:
|
|
limit_error = (
|
|
f"tar.gz member {member.name} exceeds maximum size "
|
|
f"of {max_member_bytes} bytes"
|
|
)
|
|
break
|
|
total_written += len(chunk)
|
|
if total_written > max_total_bytes:
|
|
limit_error = (
|
|
f"tar.gz archive exceeds maximum uncompressed size "
|
|
f"of {max_total_bytes} bytes"
|
|
)
|
|
break
|
|
dest.write(chunk)
|
|
except Exception as exc:
|
|
_raise_from(
|
|
error_type,
|
|
f"Failed to extract tar.gz member {member.name}: {exc}",
|
|
exc,
|
|
)
|
|
if limit_error is not None:
|
|
_raise(error_type, limit_error)
|
|
|
|
|
|
def safe_extract_archive(
|
|
archive_path: Path,
|
|
target_dir: Path,
|
|
*,
|
|
archive_file: BinaryIO | None = None,
|
|
source_name: str | None = None,
|
|
content_type: str | None = None,
|
|
error_type: type[ErrorT] = ValueError,
|
|
max_entries: int = MAX_ZIP_ENTRIES,
|
|
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
|
|
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
|
|
) -> ArchiveFormat:
|
|
"""Detect and securely extract a supported archive."""
|
|
archive_format = detect_archive_format(
|
|
archive_path,
|
|
archive_file=archive_file,
|
|
source_name=source_name,
|
|
content_type=content_type,
|
|
error_type=error_type,
|
|
)
|
|
extractor = safe_extract_zip if archive_format == "zip" else safe_extract_tar
|
|
extractor(
|
|
archive_path,
|
|
target_dir,
|
|
archive_file=archive_file,
|
|
error_type=error_type,
|
|
max_entries=max_entries,
|
|
max_member_bytes=max_member_bytes,
|
|
max_total_bytes=max_total_bytes,
|
|
)
|
|
return archive_format
|