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:
committed by
Teknium
co-authored by
Teknium
parent
0d137f1039
commit
3b9b9a7ad7
+91
-11
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user