mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
harden(pets): host-pin asset downloads + sanitize slug paths
install_pet now refuses spritesheet/pet.json URLs that aren't on a petdex host (matching thumbnail_png's existing _is_petdex_host guard), so a spoofed manifest can't redirect a download at an arbitrary host. Slugs are normalized to a single path segment before indexing into pets_dir(), closing a path-traversal vector in load_pet/remove_pet/install_pet.
This commit is contained in:
parent
e495b33bf1
commit
6afeea2bea
1 changed files with 31 additions and 4 deletions
|
|
@ -84,9 +84,25 @@ def _resolve_spritesheet(directory: Path, meta: dict) -> Path:
|
|||
return directory / "spritesheet.webp"
|
||||
|
||||
|
||||
def _safe_slug(slug: str) -> str:
|
||||
"""Normalize a slug to a single bare path segment.
|
||||
|
||||
Pet slugs index into ``pets_dir()/<slug>/`` for load/remove, so a value
|
||||
carrying path separators (``../``, absolute paths) could escape the pets
|
||||
directory. Strip every separator and reject ``.``/``..`` so callers can
|
||||
only ever name a direct child of the pets directory.
|
||||
"""
|
||||
segment = Path(str(slug).strip()).name
|
||||
if segment in ("", ".", ".."):
|
||||
return ""
|
||||
return segment
|
||||
|
||||
|
||||
def load_pet(slug: str) -> InstalledPet | None:
|
||||
"""Return the :class:`InstalledPet` for *slug*, or ``None`` if absent."""
|
||||
slug = slug.strip()
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
return None
|
||||
directory = pets_dir() / slug
|
||||
if not directory.is_dir():
|
||||
return None
|
||||
|
|
@ -135,7 +151,9 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI
|
|||
"""
|
||||
from agent.pet.manifest import find_entry
|
||||
|
||||
slug = slug.strip()
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
raise PetStoreError("invalid pet slug")
|
||||
existing = load_pet(slug)
|
||||
if existing and existing.exists and not force:
|
||||
return existing
|
||||
|
|
@ -144,6 +162,12 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI
|
|||
if entry is None:
|
||||
raise PetStoreError(f"pet '{slug}' is not in the petdex manifest")
|
||||
|
||||
# Host-pin every asset URL to petdex. The manifest is trusted (HTTPS from
|
||||
# petdex.dev), but pin the asset hosts too so a compromised/spoofed manifest
|
||||
# can't redirect the download at an arbitrary host. Matches thumbnail_png.
|
||||
if not _is_petdex_host(entry.spritesheet_url):
|
||||
raise PetStoreError(f"refusing non-petdex spritesheet host for '{slug}'")
|
||||
|
||||
directory = pets_dir() / slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
|
@ -155,7 +179,7 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI
|
|||
# Fetch the upstream pet.json if present; otherwise synthesize a minimal
|
||||
# one so the local layout is self-describing.
|
||||
meta: dict = {}
|
||||
if entry.pet_json_url:
|
||||
if entry.pet_json_url and _is_petdex_host(entry.pet_json_url):
|
||||
try:
|
||||
meta = _download_json(entry.pet_json_url, timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - non-fatal, fall back below
|
||||
|
|
@ -274,7 +298,10 @@ def remove_pet(slug: str) -> bool:
|
|||
"""Delete an installed pet directory. Returns True if anything was removed."""
|
||||
import shutil
|
||||
|
||||
directory = pets_dir() / slug.strip()
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
return False
|
||||
directory = pets_dir() / slug
|
||||
if not directory.is_dir():
|
||||
return False
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue