mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-30 11:49:50 +02:00
122 lines
4.4 KiB
Python
122 lines
4.4 KiB
Python
"""Hardened zip <-> bytes for .swarm bundles. The zip arrives from an untrusted
|
|
party, so unpack defends against zip-slip, zip-bombs, symlinks, and lying size
|
|
headers, and only ever writes into a throwaway sandbox dir (never a real store).
|
|
pack re-checks that no secret slipped past redaction before writing a byte."""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import zipfile
|
|
|
|
from .redact import find_denied_keys
|
|
|
|
MANIFEST_NAME = "manifest.json"
|
|
|
|
MAX_ENTRIES = 5000
|
|
MAX_TOTAL_BYTES = 200 * 1024 * 1024 # 200 MB uncompressed
|
|
MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB per entry
|
|
MAX_RATIO = 200 # uncompressed / compressed per entry
|
|
|
|
|
|
class BundleError(Exception):
|
|
"""Bundle is malformed or unsafe. Message is safe to show the user."""
|
|
|
|
|
|
def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> bytes:
|
|
"""payloads: bundle_id -> JSON payload (-> entities/<bid>/payload.json).
|
|
files: full zip path -> bytes (e.g. entities/<bid>/files/<rel>)."""
|
|
for bid, payload in payloads.items():
|
|
leaked = find_denied_keys(payload)
|
|
if leaked:
|
|
raise BundleError(
|
|
f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}"
|
|
)
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
|
for bid, payload in sorted(payloads.items()):
|
|
zf.writestr(f"entities/{bid}/payload.json", json.dumps(payload, indent=2))
|
|
for path, data in sorted(files.items()):
|
|
zf.writestr(path, data)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _safe_member_path(name: str, sandbox: str) -> str:
|
|
if name.startswith(("/", "\\")) or (len(name) > 1 and name[1] == ":"):
|
|
raise BundleError("bundle contains an absolute path")
|
|
dest = os.path.realpath(os.path.join(sandbox, name))
|
|
root = os.path.realpath(sandbox)
|
|
if dest != root and not dest.startswith(root + os.sep):
|
|
raise BundleError("bundle contains a path-traversal entry")
|
|
return dest
|
|
|
|
|
|
def is_zip(raw: bytes) -> bool:
|
|
return zipfile.is_zipfile(io.BytesIO(raw))
|
|
|
|
|
|
def has_member(raw: bytes, name: str) -> bool:
|
|
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
|
return name in zf.namelist()
|
|
|
|
|
|
def unpack(raw: bytes) -> str:
|
|
"""Extract into a fresh sandbox temp dir and return it. Caller deletes it."""
|
|
if len(raw) > MAX_TOTAL_BYTES:
|
|
raise BundleError("bundle is too large")
|
|
try:
|
|
zf = zipfile.ZipFile(io.BytesIO(raw))
|
|
except zipfile.BadZipFile:
|
|
raise BundleError("not a valid .swarm file")
|
|
infos = zf.infolist()
|
|
if len(infos) > MAX_ENTRIES:
|
|
raise BundleError("bundle has too many entries")
|
|
total = 0
|
|
for zi in infos:
|
|
if zi.file_size > MAX_FILE_BYTES:
|
|
raise BundleError("bundle has an oversized entry")
|
|
total += zi.file_size
|
|
if total > MAX_TOTAL_BYTES:
|
|
raise BundleError("bundle is too large uncompressed")
|
|
if zi.compress_size and zi.file_size / zi.compress_size > MAX_RATIO:
|
|
raise BundleError("bundle entry is suspiciously compressed")
|
|
mode = (zi.external_attr >> 16) & 0o170000
|
|
if mode == 0o120000:
|
|
raise BundleError("bundle contains a symlink")
|
|
|
|
sandbox = tempfile.mkdtemp(prefix="swarm-import-")
|
|
try:
|
|
written = 0
|
|
for zi in infos:
|
|
if zi.is_dir():
|
|
continue
|
|
dest = _safe_member_path(zi.filename, sandbox)
|
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
with zf.open(zi) as src, open(dest, "wb") as out:
|
|
while True:
|
|
chunk = src.read(65536)
|
|
if not chunk:
|
|
break
|
|
written += len(chunk)
|
|
if written > MAX_TOTAL_BYTES:
|
|
raise BundleError("bundle exceeded size during extraction")
|
|
out.write(chunk)
|
|
except Exception:
|
|
shutil.rmtree(sandbox, ignore_errors=True)
|
|
raise
|
|
return sandbox
|
|
|
|
|
|
def read_manifest(sandbox: str) -> dict:
|
|
path = os.path.join(sandbox, MANIFEST_NAME)
|
|
if not os.path.isfile(path):
|
|
raise BundleError("bundle has no manifest")
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
raise BundleError("bundle manifest is unreadable")
|