From 313a8c68332e126e6b5b831877da3d8c2dd3f72f Mon Sep 17 00:00:00 2001 From: Ruzzgar Date: Fri, 10 Apr 2026 18:05:30 +0300 Subject: [PATCH] fix(skills): replace string prefix check with strict path containment --- tests/tools/test_skills_hub.py | 17 +++++++++++++++++ tools/skills_hub.py | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index 9170c03e2c6..265a1228704 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -1846,6 +1846,23 @@ class TestOptionalSkillSourceBinaryAssets: assert bundle.files["assets/neutts-cli/samples/jo.txt"] == b"hello\n" assert "assets/neutts-cli/src/neutts_cli/__pycache__/cli.cpython-312.pyc" not in bundle.files + def test_fetch_rejects_sibling_directory_traversal(self, tmp_path): + optional_root = tmp_path / "optional-skills" + sibling_skill_dir = tmp_path / "optional-skills-escape" / "pwned" + optional_root.mkdir() + sibling_skill_dir.mkdir(parents=True) + (sibling_skill_dir / "SKILL.md").write_text( + "---\nname: pwned\ndescription: traversal\n---\n\nBody\n", + encoding="utf-8", + ) + + src = OptionalSkillSource() + src._optional_dir = optional_root + + bundle = src.fetch("official/../optional-skills-escape/pwned") + + assert bundle is None + class TestQuarantineBundleBinaryAssets: def test_quarantine_bundle_writes_binary_files(self, tmp_path): diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 9e2918ffc5e..0827545ae72 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -3028,7 +3028,8 @@ class OptionalSkillSource(SkillSource): # Guard against path traversal (e.g. "official/../../etc") try: resolved = skill_dir.resolve() - if not str(resolved).startswith(str(self._optional_dir.resolve())): + optional_root = self._optional_dir.resolve() + if not resolved.is_relative_to(optional_root): return None except (OSError, ValueError): return None