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>
This commit is contained in:
Emir Çoban
2026-07-14 17:33:10 +03:00
committed by GitHub
parent c1d6287e32
commit c80462e462
3 changed files with 197 additions and 40 deletions
+3 -3
View File
@@ -71,7 +71,7 @@ def test_fetch_bytes_rejects_cross_host_redirect(monkeypatch):
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url: (True, ""),
lambda url, **kwargs: (True, ""),
)
with pytest.raises(SkillImportError, match="redirect target"):
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")
@@ -91,7 +91,7 @@ def test_list_github_dir_accepts_api_github_response(monkeypatch):
)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url: (True, ""),
lambda url, **kwargs: (True, ""),
)
class _Resp:
@@ -146,7 +146,7 @@ def _mock_httpx_client(monkeypatch, response):
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url: (True, ""),
lambda url, **kwargs: (True, ""),
)
+123
View File
@@ -0,0 +1,123 @@
"""Skill importer SSRF hardening: redirects must be re-validated per hop.
The importer follows redirects manually (`_get_checked`) and re-runs the SSRF
guard on every hop with ``block_private=True``, matching the hardened web-fetch
path in ``services/search/content.py:_get_public_url``. Previously it used
``httpx``'s ``follow_redirects=True`` with the lenient guard on the *initial*
URL only, so a ``3xx`` to an internal/metadata address was still connected to.
These tests are hermetic: every host is an IP literal, so ``check_outbound_url``
resolves them locally (``getaddrinfo`` on a numeric address does no DNS) and no
network access is required. The HTTP layer is faked so no real request is made.
"""
import pytest
from services.memory import skill_importer
from services.memory.skill_importer import (
SkillImportError,
_check_fetch_url,
_fetch_bytes,
_get_checked,
parse_skill_source,
)
# Clearly-public, non-reserved IP literals for the initial (allowed) hop.
PUBLIC_A = "https://1.1.1.1/skill"
PUBLIC_B = "https://8.8.8.8/skill"
# Internal redirect targets that must be refused before connection.
LOOPBACK = "http://127.0.0.1/latest"
METADATA = "http://169.254.169.254/latest/meta-data/"
def _install_fake_client(monkeypatch, *, redirect_from, redirect_to):
"""Replace httpx.Client so `redirect_from` 302s to `redirect_to`, and any
other URL returns 200. No real socket is opened."""
class _Resp:
def __init__(self, url, status, location):
self.url = url
self.status_code = status
self.headers = {"location": location} if location else {}
self.content = b"ok"
self.text = ""
def raise_for_status(self):
return None
def json(self):
return {}
class _Client:
def __init__(self, *args, **kwargs):
# Safety invariant: the importer follows redirects by hand and
# re-runs the SSRF guard per hop, so it MUST disable httpx's own
# redirect following. Asserting ``follow_redirects is False`` here
# (not merely accepting the kwarg) makes any regression to
# ``follow_redirects=True`` fail these tests instead of passing
# silently — httpx being faked would otherwise hide the change.
assert kwargs.get("follow_redirects") is False, (
"skill importer must construct httpx.Client with "
"follow_redirects=False; got "
f"{kwargs.get('follow_redirects')!r}"
)
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get(self, url, headers=None):
if url == redirect_from:
return _Resp(url, 302, redirect_to)
return _Resp(url, 200, None)
monkeypatch.setattr(skill_importer.httpx, "Client", _Client)
# --- Guard unit: block_private=True refuses internal, allows public ----------
@pytest.mark.parametrize("url", [LOOPBACK, METADATA, "http://10.0.0.5/", "http://[::1]/"])
def test_check_fetch_url_blocks_internal(url):
with pytest.raises(SkillImportError):
_check_fetch_url(url)
@pytest.mark.parametrize("url", [PUBLIC_A, PUBLIC_B])
def test_check_fetch_url_allows_public(url):
# Should not raise for a public IP literal.
_check_fetch_url(url)
# --- Redirect revalidation: the core regression ------------------------------
@pytest.mark.parametrize("internal", [LOOPBACK, METADATA])
def test_get_checked_blocks_redirect_to_internal(monkeypatch, internal):
_install_fake_client(monkeypatch, redirect_from=PUBLIC_A, redirect_to=internal)
with pytest.raises(SkillImportError, match="blocked"):
_get_checked(PUBLIC_A)
@pytest.mark.parametrize("internal", [LOOPBACK, METADATA])
def test_fetch_bytes_blocks_redirect_to_internal(monkeypatch, internal):
# Higher-level: the public fetch helpers inherit the per-hop guard.
_install_fake_client(monkeypatch, redirect_from=PUBLIC_A, redirect_to=internal)
with pytest.raises(SkillImportError, match="blocked"):
_fetch_bytes(PUBLIC_A)
def test_skills_sh_entry_blocks_redirect_to_metadata(monkeypatch):
# The skills.sh unwrap path (user-supplied host) must also revalidate hops.
raw = "http://1.1.1.1/skills.sh" # contains "skills.sh", not "github.com"
_install_fake_client(monkeypatch, redirect_from=raw, redirect_to=METADATA)
with pytest.raises(SkillImportError, match="blocked"):
parse_skill_source(raw)
# --- Positive: a legitimate public->public redirect is still followed --------
def test_get_checked_follows_public_redirect(monkeypatch):
_install_fake_client(monkeypatch, redirect_from=PUBLIC_A, redirect_to=PUBLIC_B)
resp = _get_checked(PUBLIC_A)
assert resp.status_code == 200
assert str(resp.url) == PUBLIC_B