171 lines
6.4 KiB
Python
171 lines
6.4 KiB
Python
"""Allowlisted path and archive handling for backup and restore payloads.
|
|
|
|
Nothing in the recovery plane may resolve to a host path outside a configured root, follow a
|
|
symlink out of it, or extract an archive member that escapes its destination.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import tarfile
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
MAX_ARCHIVE_MEMBERS = 10_000
|
|
MAX_ARCHIVE_BYTES = 64 * 1024**3
|
|
_HASH_CHUNK = 1024 * 1024
|
|
_DRIVE_QUALIFIED = re.compile(r"^[A-Za-z]:")
|
|
|
|
|
|
class RecoveryPathError(Exception):
|
|
"""Raised when a path or archive member is not provably inside an allowlisted root."""
|
|
|
|
def __init__(self, code: str, message: str) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.message = message
|
|
|
|
|
|
def normalise_root(root: Path) -> Path:
|
|
"""Resolve an allowlisted root, creating it when missing so callers get a stable anchor."""
|
|
|
|
resolved = Path(root).expanduser()
|
|
if not resolved.is_absolute():
|
|
raise RecoveryPathError("PATH_NOT_ALLOWED", "recovery roots must be absolute paths")
|
|
resolved.mkdir(parents=True, exist_ok=True)
|
|
return resolved.resolve(strict=True)
|
|
|
|
|
|
def _reject_traversal(relative: str) -> PurePosixPath:
|
|
candidate = PurePosixPath(relative.replace("\\", "/"))
|
|
if candidate.is_absolute() or not relative.strip():
|
|
raise RecoveryPathError("PATH_NOT_ALLOWED", f"relative path required, got {relative!r}")
|
|
if any(part in {"..", ""} for part in candidate.parts):
|
|
raise RecoveryPathError("PATH_NOT_ALLOWED", f"path traversal rejected: {relative!r}")
|
|
if any(part.startswith("~") for part in candidate.parts):
|
|
raise RecoveryPathError("PATH_NOT_ALLOWED", f"home expansion rejected: {relative!r}")
|
|
# A drive-qualified component means different things on Windows and POSIX; refuse it on both.
|
|
if any(_DRIVE_QUALIFIED.match(part) for part in candidate.parts):
|
|
raise RecoveryPathError("PATH_NOT_ALLOWED", f"drive-qualified path rejected: {relative!r}")
|
|
return candidate
|
|
|
|
|
|
def resolve_within(root: Path, relative: str, *, must_exist: bool = False) -> Path:
|
|
"""Return ``root/relative`` proven to stay inside ``root`` after symlink resolution."""
|
|
|
|
anchor = normalise_root(root)
|
|
candidate = _reject_traversal(relative)
|
|
target = anchor.joinpath(*candidate.parts)
|
|
if must_exist and not target.exists():
|
|
raise RecoveryPathError("PAYLOAD_MISSING", f"{relative} does not exist under the root")
|
|
|
|
# Resolve the deepest component that exists, so a not-yet-created backup directory is still
|
|
# checked against the allowlisted root and a symlinked ancestor cannot smuggle a path out.
|
|
existing = target
|
|
remaining: list[str] = []
|
|
while not existing.exists():
|
|
if existing == existing.parent:
|
|
raise RecoveryPathError("PATH_NOT_ALLOWED", f"{relative} has no resolvable parent")
|
|
remaining.append(existing.name)
|
|
existing = existing.parent
|
|
resolved = existing.resolve().joinpath(*reversed(remaining))
|
|
if resolved != anchor and anchor not in resolved.parents:
|
|
raise RecoveryPathError(
|
|
"PATH_NOT_ALLOWED", f"{relative} resolves outside the allowlisted recovery root"
|
|
)
|
|
if target.is_symlink():
|
|
raise RecoveryPathError("PATH_NOT_ALLOWED", f"{relative} is a symlink and is rejected")
|
|
return target
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
while chunk := handle.read(_HASH_CHUNK):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def sha256_bytes(payload: bytes) -> str:
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def free_bytes(path: Path) -> int | None:
|
|
try:
|
|
usage = os.statvfs(path) if hasattr(os, "statvfs") else None
|
|
except OSError:
|
|
return None
|
|
if usage is not None:
|
|
return int(usage.f_bavail) * int(usage.f_frsize)
|
|
try:
|
|
import shutil
|
|
|
|
return int(shutil.disk_usage(path).free)
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def capacity_bytes(path: Path) -> int | None:
|
|
try:
|
|
import shutil
|
|
|
|
return int(shutil.disk_usage(path).total)
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def validate_archive(archive: Path, destination_root: Path) -> list[str]:
|
|
"""Validate every member of a tar archive before any extraction happens.
|
|
|
|
Bounded member count, bounded total size, no absolute members, no traversal, no symlinks,
|
|
hardlinks, devices or FIFOs.
|
|
"""
|
|
|
|
anchor = normalise_root(destination_root)
|
|
names: list[str] = []
|
|
total = 0
|
|
with tarfile.open(archive, "r:*") as handle:
|
|
for member in handle:
|
|
if len(names) >= MAX_ARCHIVE_MEMBERS:
|
|
raise RecoveryPathError(
|
|
"ARCHIVE_UNSAFE", f"archive exceeds {MAX_ARCHIVE_MEMBERS} members"
|
|
)
|
|
if member.issym() or member.islnk():
|
|
raise RecoveryPathError(
|
|
"ARCHIVE_UNSAFE", f"archive member {member.name} is a link"
|
|
)
|
|
if member.isdev() or member.isfifo():
|
|
raise RecoveryPathError(
|
|
"ARCHIVE_UNSAFE", f"archive member {member.name} is a special file"
|
|
)
|
|
if not (member.isfile() or member.isdir()):
|
|
raise RecoveryPathError(
|
|
"ARCHIVE_UNSAFE", f"archive member {member.name} has an unsupported type"
|
|
)
|
|
try:
|
|
resolved = resolve_within(anchor, member.name)
|
|
except RecoveryPathError as error:
|
|
raise RecoveryPathError(
|
|
"ARCHIVE_UNSAFE", f"archive member {member.name} escapes destination"
|
|
) from error
|
|
if anchor not in resolved.parents and resolved != anchor:
|
|
raise RecoveryPathError(
|
|
"ARCHIVE_UNSAFE", f"archive member {member.name} escapes destination"
|
|
)
|
|
total += max(0, member.size)
|
|
if total > MAX_ARCHIVE_BYTES:
|
|
raise RecoveryPathError("ARCHIVE_UNSAFE", "archive exceeds the extraction budget")
|
|
names.append(member.name)
|
|
return names
|
|
|
|
|
|
def extract_archive(archive: Path, destination_root: Path) -> list[str]:
|
|
"""Validate first, extract second; a failed validation never writes a byte."""
|
|
|
|
names = validate_archive(archive, destination_root)
|
|
anchor = normalise_root(destination_root)
|
|
with tarfile.open(archive, "r:*") as handle:
|
|
handle.extractall(anchor, filter="data")
|
|
return names
|