fix(security): SSRF protection for vision tool + path traversal guard for skill install

1. Vision SSRF (#2878, #2872):
   Add _validate_url_safe() that resolves the target hostname via DNS and
   rejects any IP in private (RFC1918), loopback, link-local, or reserved
   ranges before requests.get() is called. This blocks attacks that use
   attacker-controlled image URLs to probe internal services or cloud
   metadata endpoints (169.254.169.254).

2. Skill install path traversal (#2873):
   Add _safe_skill_dir() that validates the skill name cannot escape the
   skills/ root directory. Rejects names containing '..', absolute paths,
   and any resolved path that falls outside the custom_dir boundary.
   Applied to _add_url(), _add_package(), and delete().

Both fixes include comprehensive unit tests (19 test cases) covering
blocked patterns, edge cases, and allowed legitimate usage.

Closes #2878
Closes #2873
Ref: #2872
This commit is contained in:
kirs-hi
2026-06-11 17:40:41 +08:00
parent f5caba81d6
commit e85290cddc
3 changed files with 256 additions and 3 deletions

View File

@@ -34,6 +34,27 @@ class SkillService:
"""
self.manager = skill_manager
def _safe_skill_dir(self, name: str) -> str:
"""Derive and validate the skill directory path.
Ensures the resolved path stays within the custom_dir root,
preventing path traversal via names like ``../escaped``.
:raises ValueError: if the name would escape the skills root.
"""
if not name or not name.strip():
raise ValueError("skill name is required")
# Reject obvious traversal components.
if ".." in name or name.startswith("/") or name.startswith("\\"):
raise ValueError(f"invalid skill name (path traversal detected): {name!r}")
skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name))
root = os.path.realpath(self.manager.custom_dir)
if not skill_dir.startswith(root + os.sep) and skill_dir != root:
raise ValueError(
f"skill name {name!r} resolves outside the skills directory"
)
return skill_dir
# ------------------------------------------------------------------
# query
# ------------------------------------------------------------------
@@ -107,7 +128,7 @@ class SkillService:
if not files:
raise ValueError("skill files list is empty")
skill_dir = os.path.join(self.manager.custom_dir, name)
skill_dir = self._safe_skill_dir(name)
tmp_dir = skill_dir + ".tmp"
if os.path.exists(tmp_dir):
@@ -146,7 +167,7 @@ class SkillService:
raise ValueError("package url is required")
url = files[0]["url"]
skill_dir = os.path.join(self.manager.custom_dir, name)
skill_dir = self._safe_skill_dir(name)
with tempfile.TemporaryDirectory() as tmp_dir:
zip_path = os.path.join(tmp_dir, "package.zip")
@@ -217,7 +238,7 @@ class SkillService:
if not name:
raise ValueError("skill name is required")
skill_dir = os.path.join(self.manager.custom_dir, name)
skill_dir = self._safe_skill_dir(name)
if os.path.exists(skill_dir):
shutil.rmtree(skill_dir)
logger.info(f"[SkillService] delete: removed directory {skill_dir}")