mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-20 08:37:42 +02:00
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
131 lines
4.8 KiB
Python
131 lines
4.8 KiB
Python
"""Fetch a published .swarm, with the host allowlist that keeps this from being an SSRF hole.
|
|
|
|
The catalog is a spreadsheet other people can edit, so a row's URL is untrusted input that would
|
|
otherwise make OUR backend fetch whatever it names, including localhost and cloud metadata. Every
|
|
hop of the redirect chain is checked, not just the first, because a permitted host may redirect.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Callable, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
from typeguard import typechecked
|
|
|
|
ALLOWED_DOWNLOAD_HOSTS = (
|
|
"drive.google.com",
|
|
"drive.usercontent.google.com",
|
|
"docs.google.com",
|
|
"github.com",
|
|
"objects.githubusercontent.com",
|
|
"raw.githubusercontent.com",
|
|
)
|
|
|
|
DOWNLOAD_TIMEOUT_SECONDS = 60
|
|
MAX_PACKAGE_BYTES = 200 * 1024 * 1024
|
|
CHUNK_BYTES = 64 * 1024
|
|
# Drill seam: cap the download at this many bytes per second so a human can watch the ring fill. Unset in real life.
|
|
THROTTLE_ENV = "OSW_MARKETPLACE_THROTTLE_BPS"
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# (bytes received so far, total bytes or 0 when the server did not say)
|
|
ProgressCallback = Callable[[int, int], None]
|
|
|
|
|
|
class DownloadRefused(Exception):
|
|
"""The URL is not somewhere we are willing to fetch from."""
|
|
|
|
|
|
@typechecked
|
|
def host_allowed(url: str) -> bool:
|
|
parsed = urlparse((url or "").strip())
|
|
if parsed.scheme != "https" or not parsed.hostname:
|
|
return False
|
|
host = parsed.hostname.lower()
|
|
return any(host == allowed or host.endswith("." + allowed) for allowed in ALLOWED_DOWNLOAD_HOSTS)
|
|
|
|
|
|
class AllowlistRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
"""Drive answers a download with a redirect, so redirects have to be followed; each new
|
|
location is re-checked so a permitted host cannot bounce us onto a private address."""
|
|
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
|
|
if not host_allowed(newurl):
|
|
raise DownloadRefused(f"refused a redirect to {urlparse(newurl).hostname or newurl}")
|
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
|
|
|
|
@typechecked
|
|
def throttle_bytes_per_second() -> int:
|
|
raw = (os.environ.get(THROTTLE_ENV) or "").strip()
|
|
if not raw:
|
|
return 0
|
|
try:
|
|
bps = int(raw)
|
|
except ValueError:
|
|
return 0
|
|
if bps > 0:
|
|
logger.warning("marketplace downloads throttled to %d bytes/s by %s (drill seam)", bps, THROTTLE_ENV)
|
|
return max(0, bps)
|
|
|
|
|
|
@typechecked
|
|
def declared_total(response: object) -> int:
|
|
"""Content-Length when the server sent one, else 0 (the ring then spins instead of filling)."""
|
|
try:
|
|
return max(0, int(response.headers.get("Content-Length") or 0)) # type: ignore[attr-defined]
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
@typechecked
|
|
def download_package(url: str, on_progress: Optional[ProgressCallback] = None) -> bytes:
|
|
"""The bundle's bytes, or DownloadRefused. Never returns a partial or oversized body. Reads in
|
|
chunks and reports (received, total) after each one so an install can show a real ring."""
|
|
if not host_allowed(url):
|
|
raise DownloadRefused("this package is not hosted somewhere OpenSwarm will download from")
|
|
opener = urllib.request.build_opener(AllowlistRedirectHandler())
|
|
request = urllib.request.Request(url, headers={"User-Agent": "OpenSwarm-Marketplace/1.0"})
|
|
try:
|
|
with opener.open(request, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response:
|
|
total = declared_total(response)
|
|
if total > MAX_PACKAGE_BYTES:
|
|
raise DownloadRefused("the package is too large")
|
|
throttle = throttle_bytes_per_second()
|
|
chunks: list[bytes] = []
|
|
received = 0
|
|
while True:
|
|
chunk = response.read(CHUNK_BYTES)
|
|
if not chunk:
|
|
break
|
|
received += len(chunk)
|
|
if received > MAX_PACKAGE_BYTES:
|
|
raise DownloadRefused("the package is too large")
|
|
chunks.append(chunk)
|
|
if on_progress is not None:
|
|
on_progress(received, total)
|
|
if throttle:
|
|
time.sleep(len(chunk) / throttle)
|
|
raw = b"".join(chunks)
|
|
except urllib.error.HTTPError as e:
|
|
raise DownloadRefused(f"the download returned {e.code}")
|
|
except DownloadRefused:
|
|
raise
|
|
except Exception as e:
|
|
raise DownloadRefused(f"the download failed: {e}")
|
|
if len(raw) > MAX_PACKAGE_BYTES:
|
|
raise DownloadRefused("the package is too large")
|
|
if not raw:
|
|
raise DownloadRefused("the download was empty")
|
|
return raw
|
|
|
|
|
|
@typechecked
|
|
def package_filename(listing_id: str, title: str) -> str:
|
|
base = (listing_id or title or "package").strip() or "package"
|
|
return f"{base}.swarm"
|