fix(skills): guard uninstall lock paths

Validate Skills Hub lock-file install paths at both ends of the
lifecycle so a poisoned or malformed lock.json entry cannot drive
shutil.rmtree to a location outside SKILLS_DIR:

- HubLockFile.record_install rejects empty/'.'/absolute/traversal/
  Windows-drive paths at write time, and requires the final path
  component to match the skill name (shape: '<skill>' or
  '<category>/<skill>').
- install_from_quarantine resolves its destination through the same
  validator, catching symlink/junction redirects inside skills/.
- uninstall_skill resolves the lock entry through the new validator
  before rmtree. Refuses anything that resolves to SKILLS_DIR itself
  (empty/dot paths) or to a target outside SKILLS_DIR (absolute paths,
  traversal, symlinked dirs in skills/ pointing outward).
- 14 focused regression tests covering each rejection class plus a
  symlink-redirect case.

E2E verified: hand-crafted poisoned lock.json entries (absolute path,
empty install_path, traversal) all refuse and leave the targeted
victim untouched; legitimate uninstall still succeeds.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
This commit is contained in:
峯岸 亮
2026-05-25 06:13:36 -07:00
committed by Teknium
co-authored by Teknium
parent 0d137f1039
commit 3b9b9a7ad7
2 changed files with 311 additions and 11 deletions
+91 -11
View File
@@ -124,6 +124,69 @@ def _validate_category_name(category: str) -> str:
return _normalize_bundle_path(category, field_name="category", allow_nested=False)
def _normalize_lock_install_path(install_path: str, skill_name: str) -> str:
"""Validate a skill install path before it touches the lock file or disk.
Lock-file ``install_path`` entries are the source-of-truth for where
``uninstall_skill`` will call ``shutil.rmtree``. A poisoned or buggy
entry — empty string, ``"."``, an absolute path, ``../..`` traversal,
or anything whose final component doesn't match the skill name — would
let ``rmtree`` wipe either the entire ``skills/`` tree or content
outside it.
Enforce that ``install_path`` is exactly ``<skill_name>`` or
``<category>/<skill_name>``. Reject anything else.
"""
safe_skill_name = _validate_skill_name(skill_name)
normalized = _normalize_bundle_path(
install_path,
field_name="install path",
allow_nested=True,
)
parts = normalized.split("/")
if len(parts) not in {1, 2} or parts[-1] != safe_skill_name:
raise ValueError(f"Unsafe install path: {install_path}")
return normalized
def _is_path_redirect(path: Path) -> bool:
"""True when ``path`` is a symlink or (on Windows) a directory junction.
Either form lets an attacker who can write into the ``skills/`` tree
redirect a subsequent ``rmtree`` to content outside it. ``is_junction``
only exists on Python 3.12+ Windows; gate with ``hasattr``.
"""
return path.is_symlink() or (hasattr(path, "is_junction") and path.is_junction())
def _resolve_lock_install_path(install_path: str, skill_name: str) -> Path:
"""Resolve a lock-file install path without allowing escapes from ``SKILLS_DIR``.
Two layers of defence on top of the existing ``is_relative_to`` check
that's been on main:
1. Walk the path component-by-component and refuse if any intermediate
component is a symlink/junction (a path resolution that follows a
symlink to outside skills/ would otherwise be hidden by Path.resolve).
2. After resolve(), reject not just escape-out but also ``resolved == SKILLS_DIR``
— an empty/``"."``/``""`` install_path resolves to the skills root itself,
and ``rmtree(SKILLS_DIR)`` would wipe every installed skill.
"""
normalized = _normalize_lock_install_path(install_path, skill_name)
skills_root = SKILLS_DIR.resolve()
target = SKILLS_DIR
for part in normalized.split("/"):
target = target / part
if _is_path_redirect(target):
raise ValueError(f"Unsafe install path: {install_path}")
target = target.resolve()
if target == skills_root or not target.is_relative_to(skills_root):
raise ValueError(f"Unsafe install path: {install_path}")
return target
def _guarded_http_get(url: str, *, timeout: int = 20) -> Optional[httpx.Response]:
"""Fetch a URL with SSRF and redirect-target validation."""
current_url = url
@@ -2788,14 +2851,20 @@ class HubLockFile:
files: List[str],
metadata: Optional[Dict[str, Any]] = None,
) -> None:
# Validate both the skill name and the install path SHAPE before
# writing into lock.json. A poisoned lock entry is the precondition
# for the uninstall_skill rmtree-escape; reject malformed input at
# write time so the file never carries the bad state.
safe_name = _validate_skill_name(name)
safe_install_path = _normalize_lock_install_path(install_path, safe_name)
data = self.load()
data["installed"][name] = {
data["installed"][safe_name] = {
"source": source,
"identifier": identifier,
"trust_level": trust_level,
"scan_verdict": scan_verdict,
"content_hash": skill_hash,
"install_path": install_path,
"install_path": safe_install_path,
"files": files,
"metadata": metadata or {},
"installed_at": datetime.now(timezone.utc).isoformat(),
@@ -2943,9 +3012,14 @@ def install_from_quarantine(
raise ValueError(f"Unsafe quarantine path: {quarantine_path}")
if safe_category:
install_dir = SKILLS_DIR / safe_category / safe_skill_name
install_rel_path = f"{safe_category}/{safe_skill_name}"
else:
install_dir = SKILLS_DIR / safe_skill_name
install_rel_path = safe_skill_name
# Resolve via the same lock-path validator the uninstaller uses. Catches
# symlink-in-skills-tree redirects at install time so the lock entry's
# path can never refer to a redirected target.
install_dir = _resolve_lock_install_path(install_rel_path, safe_skill_name)
if install_dir.exists():
shutil.rmtree(install_dir)
@@ -2999,14 +3073,20 @@ def uninstall_skill(skill_name: str) -> Tuple[bool, str]:
if not entry:
return False, f"'{skill_name}' is not a hub-installed skill (may be a builtin)"
install_path = SKILLS_DIR / entry["install_path"]
# Prevent path traversal from poisoned lock.json entries
# Validate the lock entry's install_path against the skill name. This is
# the destructive boundary — anything that falls through to the rmtree
# below MUST be inside SKILLS_DIR and MUST NOT be SKILLS_DIR itself
# (an empty/"."/"/" install_path would otherwise wipe the entire tree).
# _resolve_lock_install_path enforces shape (<skill_name> or
# <category>/<skill_name>), rejects absolute/traversal paths, and walks
# the path component-by-component refusing symlink/junction redirects.
try:
resolved = install_path.resolve()
if not resolved.is_relative_to(SKILLS_DIR.resolve()):
return False, f"Refusing to remove '{entry['install_path']}': resolves outside skills directory"
except (ValueError, OSError):
return False, f"Refusing to remove '{entry['install_path']}': path resolution failed"
install_path = _resolve_lock_install_path(
entry.get("install_path", ""), skill_name
)
except ValueError as exc:
return False, f"Refusing to uninstall '{skill_name}': {exc}"
if install_path.exists():
shutil.rmtree(install_path)