155 lines
5.7 KiB
Python
155 lines
5.7 KiB
Python
"""Safe tarball extraction for agent image bundles."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import tarfile
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
# 500 MB default limit to guard against decompression bombs
|
|
DEFAULT_MAX_BYTES = 500 * 1024 * 1024
|
|
|
|
# Maximum number of entries to prevent zip-bomb style attacks
|
|
DEFAULT_MAX_ENTRIES = 10_000
|
|
|
|
|
|
class ExtractionError(Exception):
|
|
"""
|
|
Raised when a tarball fails safety checks during extraction.
|
|
|
|
Safety violations include path traversal, symlinks/hardlinks,
|
|
special file types (FIFOs, character/block devices), decompression
|
|
bombs, and entry count bombs.
|
|
"""
|
|
|
|
|
|
def extract_safe(
|
|
source: Path | bytes,
|
|
dest: Path,
|
|
*,
|
|
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
max_entries: int = DEFAULT_MAX_ENTRIES,
|
|
) -> Path:
|
|
"""
|
|
Extract a tarball to *dest* with safety checks.
|
|
|
|
Rejects:
|
|
|
|
- Path traversal (members outside *dest* via ``..`` or
|
|
absolute paths)
|
|
- Symlinks and hardlinks (could escape *dest*)
|
|
- Special file types -- FIFOs, character/block devices,
|
|
sockets (only regular files and directories are allowed).
|
|
A FIFO smuggled in as ``config.yaml`` would otherwise be
|
|
created on disk and hang the worker forever when the spec
|
|
loader calls ``read_text()`` on it.
|
|
- Decompression bombs (total extracted bytes > *max_bytes*)
|
|
- Entry count bombs (more than *max_entries* files)
|
|
|
|
:param source: Path to a ``.tar.gz`` / ``.tar`` file, or raw
|
|
tarball bytes (e.g. from an HTTP upload).
|
|
:param dest: Directory to extract into (created if needed).
|
|
:param max_bytes: Maximum total extracted size in bytes.
|
|
Defaults to 500 MB.
|
|
:param max_entries: Maximum number of tar entries. Defaults
|
|
to 10,000.
|
|
:returns: The *dest* path for convenience.
|
|
:raises ExtractionError: If any safety check fails.
|
|
:raises FileNotFoundError: If *source* is a :class:`Path` and
|
|
does not exist.
|
|
"""
|
|
if isinstance(source, Path):
|
|
if not source.exists():
|
|
raise FileNotFoundError(f"tarball not found: {source}")
|
|
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
resolved_dest = dest.resolve()
|
|
|
|
total_bytes = 0
|
|
entry_count = 0
|
|
|
|
with _open_tar(source) as tf:
|
|
for member in tf:
|
|
entry_count += 1
|
|
if entry_count > max_entries:
|
|
raise ExtractionError(f"tarball exceeds max entry count ({max_entries})")
|
|
|
|
_check_member_safety(member, resolved_dest)
|
|
|
|
if member.isfile():
|
|
total_bytes += member.size
|
|
if total_bytes > max_bytes:
|
|
raise ExtractionError(
|
|
f"tarball exceeds max extracted size ({max_bytes} bytes)"
|
|
)
|
|
|
|
# Defense in depth on top of _check_member_safety: only
|
|
# regular files and directories reach this point, so
|
|
# ``filter='data'`` (Python >= 3.12) is a no-op for valid
|
|
# members but rejects anything malicious that slipped past
|
|
# the explicit checks above.
|
|
tf.extract(member, dest, set_attrs=False, filter="data")
|
|
|
|
return dest
|
|
|
|
|
|
def _open_tar(source: Path | bytes) -> tarfile.TarFile:
|
|
"""
|
|
Open a tarball from a file path or raw bytes.
|
|
|
|
:param source: Path to a ``.tar.gz`` / ``.tar`` file, or raw
|
|
tarball bytes.
|
|
:returns: An opened :class:`tarfile.TarFile` ready for
|
|
iteration.
|
|
:raises ExtractionError: If the data is not a valid tarball.
|
|
"""
|
|
try:
|
|
if isinstance(source, bytes):
|
|
return tarfile.open(fileobj=io.BytesIO(source), mode="r:*")
|
|
return tarfile.open(source, "r:*")
|
|
except (tarfile.ReadError, tarfile.CompressionError) as exc:
|
|
raise ExtractionError(f"invalid tarball: {exc}") from exc
|
|
|
|
|
|
def _check_member_safety(member: tarfile.TarInfo, resolved_dest: Path) -> None:
|
|
"""
|
|
Validate a single tar member against safety rules.
|
|
|
|
:param member: The :class:`tarfile.TarInfo` entry to validate.
|
|
:param resolved_dest: The resolved (absolute) extraction
|
|
destination directory, used for containment checks.
|
|
:raises ExtractionError: If the member is not a regular file or
|
|
directory (symlink, hardlink, FIFO, device, socket), uses an
|
|
absolute path, contains ``..`` traversal, or resolves outside
|
|
*resolved_dest*.
|
|
"""
|
|
# Allow-list the member type: only regular files and directories
|
|
# may be extracted. A deny-list (block links only) is what let
|
|
# FIFOs / device nodes through -- a FIFO named
|
|
# ``config.yaml`` passes ``exists()`` but hangs the worker forever
|
|
# on ``read_text()``. issym()/islnk() are covered by this check
|
|
# (a link is neither isfile() nor isdir()); they keep a dedicated
|
|
# branch below only for a clearer error message.
|
|
if not (member.isfile() or member.isdir()):
|
|
if member.issym() or member.islnk():
|
|
raise ExtractionError(f"tarball contains a link (not allowed): {member.name!r}")
|
|
raise ExtractionError(
|
|
f"tarball contains an unsupported entry type "
|
|
f"(only regular files and directories are allowed): "
|
|
f"{member.name!r}"
|
|
)
|
|
|
|
# Reject absolute paths
|
|
if PurePosixPath(member.name).is_absolute():
|
|
raise ExtractionError(f"tarball contains an absolute path: {member.name!r}")
|
|
|
|
# Reject path traversal via .. segments
|
|
parts = PurePosixPath(member.name).parts
|
|
if ".." in parts:
|
|
raise ExtractionError(f"tarball contains path traversal: {member.name!r}")
|
|
|
|
# Post-resolution containment check
|
|
resolved_target = (resolved_dest / member.name).resolve()
|
|
if not resolved_target.is_relative_to(resolved_dest):
|
|
raise ExtractionError(f"tarball member escapes destination: {member.name!r}")
|