Files
odysseus/tests/test_skill_importer.py
T
Emir Çoban c80462e462 fix(skills): block private SSRF targets and revalidate redirects in importer (#5261)
* Harden skill importer against SSRF: block private targets + revalidate redirects per hop

The skill importer validated only the initial URL with the lenient SSRF guard
(block_private=False) and then fetched with follow_redirects=True, so a 3xx to
an internal/metadata address (169.254.169.254, 127.0.0.1, RFC-1918) was still
connected to — inconsistent with the hardened services/search/content.py
:_get_public_url path.

Add a _get_checked() helper that follows redirects manually and re-runs the
SSRF guard with block_private=True on every hop, and route all three fetch
sites (skills.sh unwrap, _fetch_bytes, _list_github_dir) through it. GitHub's
own redirects and the final-host _assert_github_url checks are preserved.

Adds hermetic regression tests (IP-literal hosts, faked HTTP layer) and updates
the existing mock signature for the new block_private kwarg.

Defense-in-depth: the endpoint is admin-gated (require_admin) and admins are
trusted per THREAT_MODEL.md, so this is not a cross-boundary vulnerability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: enforce follow_redirects=False invariant in mock client

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:33:10 -06:00

179 lines
4.8 KiB
Python

"""Skill URL importer — GitHub path parsing."""
import pytest
from services.memory.skill_importer import (
ResolvedSource,
SkillImportError,
_assert_github_url,
_fetch_bytes,
_list_github_dir,
parse_skill_source,
)
def test_parse_github_blob_skill_md():
src = parse_skill_source(
"https://github.com/anthropics/skills/blob/main/skills/pdf/SKILL.md"
)
assert src.owner == "anthropics"
assert src.repo == "skills"
assert src.ref == "main"
assert src.path.endswith("skills/pdf/SKILL.md")
def test_parse_github_tree_directory():
src = parse_skill_source(
"https://github.com/example/my-skills/tree/develop/caveman-skill"
)
assert src.owner == "example"
assert src.repo == "my-skills"
assert src.ref == "develop"
assert src.path == "caveman-skill"
def test_parse_raw_github():
src = parse_skill_source(
"https://raw.githubusercontent.com/o/r/main/path/SKILL.md"
)
assert src.owner == "o"
assert src.repo == "r"
assert src.ref == "main"
assert src.path == "path/SKILL.md"
def test_rejects_non_github():
with pytest.raises(SkillImportError):
parse_skill_source("https://example.com/skill.md")
def test_fetch_bytes_rejects_cross_host_redirect(monkeypatch):
class _Resp:
url = "https://evil.example/secret"
status_code = 200
content = b"x"
def raise_for_status(self):
return None
class _Client:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get(self, url, headers=None):
return _Resp()
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url, **kwargs: (True, ""),
)
with pytest.raises(SkillImportError, match="redirect target"):
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")
def test_assert_github_url_allows_api_host():
_assert_github_url(
"https://api.github.com/repos/o/r/contents?ref=main",
context="redirect target",
)
def test_list_github_dir_accepts_api_github_response(monkeypatch):
monkeypatch.setattr(
"services.memory.skill_importer._fetch_text",
lambda url: "# skill\n",
)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url, **kwargs: (True, ""),
)
class _Resp:
url = "https://api.github.com/repos/o/r/contents?ref=main"
status_code = 200
def raise_for_status(self):
return None
def json(self):
return [{
"name": "SKILL.md",
"type": "file",
"download_url": "https://raw.githubusercontent.com/o/r/main/SKILL.md",
}]
class _Client:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get(self, url, headers=None):
return _Resp()
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
out = {}
src = ResolvedSource(owner="o", repo="r", ref="main", path="")
_list_github_dir(src, "", out)
assert "SKILL.md" in out
def _mock_httpx_client(monkeypatch, response):
class _Client:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get(self, url, headers=None):
return response
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url, **kwargs: (True, ""),
)
def test_list_github_dir_surfaces_rate_limit(monkeypatch):
class _Resp:
url = "https://api.github.com/repos/o/r/contents?ref=main"
status_code = 403
def json(self):
return {"message": "API rate limit exceeded for 203.0.113.1"}
_mock_httpx_client(monkeypatch, _Resp())
src = ResolvedSource(owner="o", repo="r", ref="main", path="")
with pytest.raises(SkillImportError, match="rate limit"):
_list_github_dir(src, "", {})
def test_fetch_bytes_surfaces_github_error_detail(monkeypatch):
class _Resp:
url = "https://raw.githubusercontent.com/o/r/main/SKILL.md"
status_code = 403
content = b""
def json(self):
return {"message": "Forbidden"}
_mock_httpx_client(monkeypatch, _Resp())
with pytest.raises(SkillImportError, match="GitHub request failed \\(403\\): Forbidden"):
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")