mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-04 05:52:19 +02:00
fix(skills): harden skill import against DNS rebinding and SSRF TOCTOU (#5986)
* fix(skill-importer): validate URL scheme and improve skills.sh handling * fix(skill-importer): enhance DNS resolution and SSRF protection in fetch URL handling * fix(url-safety): add allowed_dist parameter to check_outbound_url for flexible private blocking * test(skill-importer): add comprehensive tests for URL parsing and outbound checks * ensure newline at end of file in test_check_outbound_url_allows_public_ip * fix(skill-importer): improve TLS certificate handling in _get_checked function * fix(skill-importer): enhance _check_fetch_url to handle both hostnames and full URLs * fix(skill-importer): enhance parse_skill_source to support skills.sh URLs in path and netloc * fix(skill-importer): simplify skills.sh hostname check in parse_skill_source * fix(skill-importer): enhance parse_skill_source to identify skills.sh URLs in path and handle localhost/IP addresses * fix(skill-importer): enhance _resolve_and_check_url to validate all resolved IP addresses and prevent TOCTOU vulnerabilities * fix(skill-importer): enhance parse_skill_source to support schemeless GitHub and skills.sh URLs * fix(memory): resolve CodeQL URL sanitization warning and restore _check_fetch_url test alias * fix(memory): pin skill fetch sockets without rewriting URLs * fix(memory): reject unsupported skill wrapper hosts * refactor(url-safety): remove unused importer exception * test(memory): keep redirect regression hermetic * test(dns-rebinding): add test for _PinnedTransport to ensure connection to pinned IP * fix(skill-importer): enhance skills.sh support to extract GitHub links from page content * fix(skill-importer): improve URL scheme validation for GitHub and skills.sh links * fix(skills): reject unusable skill URLs instead of guessing Resolving a skills.sh link by scraping the first github.com URL out of the page body cannot work. Skill pages only ever link the repository root, never the skill's subdirectory, so every skill in a repo resolved to the same bundle: importing skills.sh/anthropics/skills/pdf walked the whole monorepo, saturated the 64-file cap, and installed algorithmic-art behind an ok:true response. Restore the redirect-target unwrap and fail with a message that says what to do instead. Also report the real reason a URL is rejected. The scheme check keyed off "://" appearing anywhere in the string, so a supplied-but-unusable URL came back as "URL is required", and a schemeless URL carrying "://" in its query was reported as an unsupported scheme. Key off the parsed scheme and let opaque schemes (mailto:, javascript:) and a schemeless host:port fall through to the host check. * test(skills): tighten the real-socket pinning regression The handler swallowed its own exceptions, so a failure inside it surfaced as a confusing assertion on the captured client address. Record the exception and assert on it, run the thread as a daemon, and close the listening socket from the test so a hang cannot outlive the run. Also drop the duplicate ipaddress import and the missing newline. * fix(skills): require exact GitHub skill URLs * test(skills): read complete pinned request headers --------- Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Co-authored-by: Léo <leograndcontact@gmail.com>
This commit is contained in:
@@ -1,16 +1,18 @@
|
|||||||
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
|
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, Iterable, List, Optional, Tuple, cast
|
||||||
from urllib.parse import quote, urljoin, urlparse
|
from urllib.parse import quote, urljoin, urlparse
|
||||||
|
|
||||||
|
import httpcore
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import _default_resolver, check_outbound_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ TEXT_NAMES = {"skill.md", "license", "license.md", "readme.md"}
|
|||||||
_GITHUB_HOSTS = frozenset({
|
_GITHUB_HOSTS = frozenset({
|
||||||
"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com",
|
"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com",
|
||||||
})
|
})
|
||||||
|
_SKILLS_SH_HOSTS = frozenset({"skills.sh", "www.skills.sh"})
|
||||||
|
|
||||||
|
|
||||||
def _github_host(url: str) -> str:
|
def _github_host(url: str) -> str:
|
||||||
@@ -72,18 +75,158 @@ def _is_text_file(name: str) -> bool:
|
|||||||
_MAX_FETCH_REDIRECTS = 5
|
_MAX_FETCH_REDIRECTS = 5
|
||||||
|
|
||||||
|
|
||||||
def _check_fetch_url(url: str) -> None:
|
def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
|
||||||
"""SSRF guard for skill-import fetches (defense-in-depth).
|
"""Parse and de-duplicate one resolver snapshot in resolver order."""
|
||||||
|
ips: List[ipaddress._BaseAddress] = []
|
||||||
|
seen = set()
|
||||||
|
for raw in raw_ips:
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(raw.split("%", 1)[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if ip in seen:
|
||||||
|
continue
|
||||||
|
seen.add(ip)
|
||||||
|
ips.append(ip)
|
||||||
|
return ips
|
||||||
|
|
||||||
Skill bundles only ever come from public GitHub, never an internal
|
|
||||||
address, so block private/loopback/link-local targets on every hop —
|
def _resolve_and_check_url(url: str) -> List[ipaddress._BaseAddress]:
|
||||||
matching the hardened web-fetch path in
|
"""Return the exact address snapshot approved for one fetch hop."""
|
||||||
``services/search/content.py:_get_public_url`` rather than the lenient
|
resolved_ips: List[str] = []
|
||||||
default used for admin-configured model endpoints.
|
|
||||||
"""
|
def _recording_resolver(host: str) -> List[str]:
|
||||||
ok, reason = check_outbound_url(url, block_private=True)
|
answers = list(_default_resolver(host))
|
||||||
|
resolved_ips[:] = answers
|
||||||
|
return answers
|
||||||
|
|
||||||
|
ok, reason = check_outbound_url(
|
||||||
|
url,
|
||||||
|
block_private=True,
|
||||||
|
resolver=_recording_resolver,
|
||||||
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise SkillImportError(reason)
|
raise SkillImportError(f"outbound URL blocked: {reason}")
|
||||||
|
|
||||||
|
pinned_ips = _validated_ips(resolved_ips)
|
||||||
|
if not pinned_ips:
|
||||||
|
raise SkillImportError("outbound URL blocked: host did not resolve to a usable address")
|
||||||
|
return pinned_ips
|
||||||
|
|
||||||
|
|
||||||
|
# Backward compatibility alias for tests importing _check_fetch_url directly
|
||||||
|
_check_fetch_url = _resolve_and_check_url
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedBackend(httpcore.NetworkBackend):
|
||||||
|
"""Connect only to addresses from one validated DNS snapshot."""
|
||||||
|
|
||||||
|
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
||||||
|
self._ips = [str(ip) for ip in ips]
|
||||||
|
self._real = httpcore.SyncBackend()
|
||||||
|
|
||||||
|
def connect_tcp(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
timeout: float | None = None,
|
||||||
|
local_address: str | None = None,
|
||||||
|
socket_options=None,
|
||||||
|
):
|
||||||
|
deadline = None if timeout is None else time.monotonic() + timeout
|
||||||
|
last_exc: Optional[Exception] = None
|
||||||
|
for ip in self._ips:
|
||||||
|
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
||||||
|
try:
|
||||||
|
return self._real.connect_tcp(
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
remaining,
|
||||||
|
local_address,
|
||||||
|
socket_options,
|
||||||
|
)
|
||||||
|
except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if deadline is not None and time.monotonic() >= deadline:
|
||||||
|
break
|
||||||
|
if last_exc is not None:
|
||||||
|
raise last_exc
|
||||||
|
raise httpcore.ConnectError("no validated address available")
|
||||||
|
|
||||||
|
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||||
|
return self._real.connect_unix_socket(path, timeout, socket_options)
|
||||||
|
|
||||||
|
def sleep(self, seconds: float) -> None:
|
||||||
|
return self._real.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
_HTTPCORE_TO_HTTPX_EXC = {
|
||||||
|
httpcore.ConnectError: httpx.ConnectError,
|
||||||
|
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||||
|
httpcore.LocalProtocolError: httpx.LocalProtocolError,
|
||||||
|
httpcore.NetworkError: httpx.NetworkError,
|
||||||
|
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||||
|
httpcore.ProtocolError: httpx.ProtocolError,
|
||||||
|
httpcore.ProxyError: httpx.ProxyError,
|
||||||
|
httpcore.ReadError: httpx.ReadError,
|
||||||
|
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||||
|
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||||
|
httpcore.TimeoutException: httpx.TimeoutException,
|
||||||
|
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
|
||||||
|
httpcore.WriteError: httpx.WriteError,
|
||||||
|
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _PinnedTransport(httpx.BaseTransport):
|
||||||
|
"""Pin socket connects while preserving URL authority, Host, and TLS SNI."""
|
||||||
|
|
||||||
|
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
||||||
|
self._pinned_ips = list(ips)
|
||||||
|
self._pool = httpcore.ConnectionPool(
|
||||||
|
ssl_context=httpx.create_ssl_context(),
|
||||||
|
http1=True,
|
||||||
|
http2=False,
|
||||||
|
network_backend=_PinnedBackend(ips),
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
core_request = httpcore.Request(
|
||||||
|
method=request.method,
|
||||||
|
url=httpcore.URL(
|
||||||
|
scheme=request.url.raw_scheme,
|
||||||
|
host=request.url.raw_host,
|
||||||
|
port=request.url.port,
|
||||||
|
target=request.url.raw_path,
|
||||||
|
),
|
||||||
|
headers=request.headers.raw,
|
||||||
|
content=request.stream,
|
||||||
|
extensions=request.extensions,
|
||||||
|
)
|
||||||
|
core_response = None
|
||||||
|
try:
|
||||||
|
core_response = self._pool.handle_request(core_request)
|
||||||
|
content = b"".join(cast(Iterable[bytes], core_response.stream))
|
||||||
|
except Exception as exc:
|
||||||
|
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||||
|
if mapped is not None:
|
||||||
|
raise mapped(str(exc)) from exc
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if core_response is not None:
|
||||||
|
core_response.close()
|
||||||
|
|
||||||
|
return httpx.Response(
|
||||||
|
status_code=core_response.status,
|
||||||
|
headers=core_response.headers,
|
||||||
|
content=content,
|
||||||
|
extensions=core_response.extensions,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._pool.close()
|
||||||
|
|
||||||
|
|
||||||
def _get_checked(
|
def _get_checked(
|
||||||
@@ -100,49 +243,76 @@ def _get_checked(
|
|||||||
hand lets us re-validate every hop, closing that blind-SSRF gap.
|
hand lets us re-validate every hop, closing that blind-SSRF gap.
|
||||||
"""
|
"""
|
||||||
current = url
|
current = url
|
||||||
with httpx.Client(follow_redirects=False, timeout=timeout) as client:
|
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
||||||
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
pinned_ips = _resolve_and_check_url(current)
|
||||||
_check_fetch_url(current)
|
with httpx.Client(
|
||||||
|
transport=_PinnedTransport(pinned_ips),
|
||||||
|
follow_redirects=False,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
r = client.get(current, headers=headers)
|
r = client.get(current, headers=headers)
|
||||||
if r.status_code in (301, 302, 303, 307, 308):
|
|
||||||
location = r.headers.get("location")
|
if r.status_code in (301, 302, 303, 307, 308):
|
||||||
if not location:
|
location = r.headers.get("location")
|
||||||
return r
|
if not location:
|
||||||
current = urljoin(str(r.url), location)
|
return r
|
||||||
continue
|
current = urljoin(str(r.url), location)
|
||||||
return r
|
continue
|
||||||
|
return r
|
||||||
raise SkillImportError("too many redirects while fetching skill bundle")
|
raise SkillImportError("too many redirects while fetching skill bundle")
|
||||||
|
|
||||||
|
|
||||||
def parse_skill_source(url: str) -> ResolvedSource:
|
def parse_skill_source(url: str) -> ResolvedSource:
|
||||||
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
|
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
|
||||||
raw = (url or "").strip()
|
url = (url or "").strip()
|
||||||
if not raw:
|
if not url:
|
||||||
raise SkillImportError("URL is required")
|
raise SkillImportError("URL is required")
|
||||||
|
|
||||||
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
|
# ``urlparse`` only reports an unambiguous scheme when the URL carries the
|
||||||
if "skills.sh" in raw and "github.com" not in raw:
|
# ``scheme://`` form. Opaque schemes (``mailto:``, ``javascript:``) and a
|
||||||
r = _get_checked(raw, timeout=20.0)
|
# schemeless ``host:port`` both parse a "scheme" that is not one, so they
|
||||||
|
# fall through to the host check below and are rejected on the host instead.
|
||||||
|
scheme = urlparse(url).scheme.lower()
|
||||||
|
if scheme not in ("http", "https"):
|
||||||
|
if scheme and url.lower().startswith(f"{scheme}://"):
|
||||||
|
raise SkillImportError(f"unsupported URL scheme: {scheme}")
|
||||||
|
# Schemeless "github.com/owner/repo" — accept only a supported host.
|
||||||
|
rough_host = (urlparse("//" + url).hostname or "").lower()
|
||||||
|
if rough_host not in _GITHUB_HOSTS and rough_host not in _SKILLS_SH_HOSTS:
|
||||||
|
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
|
||||||
|
url = "https://" + url
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
hostname = (parsed.hostname or "").lower()
|
||||||
|
if hostname not in _GITHUB_HOSTS and hostname not in _SKILLS_SH_HOSTS:
|
||||||
|
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
|
||||||
|
|
||||||
|
# A skills.sh link is only usable if it redirects to an exact supported
|
||||||
|
# GitHub host. Scraping the page body for a github.com link cannot work:
|
||||||
|
# skill pages only ever link the repository root, never the skill's
|
||||||
|
# subdirectory, so the scrape resolves every skill in a repo to the same
|
||||||
|
# (wrong) bundle. Fail with an actionable message instead.
|
||||||
|
if hostname in _SKILLS_SH_HOSTS:
|
||||||
|
r = _get_checked(url, timeout=20.0)
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
raise _github_response_error(r)
|
raise _github_response_error(r)
|
||||||
final = str(r.url)
|
final = str(r.url)
|
||||||
_assert_github_url(final, context="redirect target")
|
if _github_host(final) not in _GITHUB_HOSTS:
|
||||||
# Page may embed a github link; prefer final URL if redirected.
|
raise SkillImportError(
|
||||||
if "github.com" in final:
|
"skills.sh did not redirect to GitHub — open the skill's "
|
||||||
raw = final
|
"repository on GitHub, navigate to the exact skill folder or "
|
||||||
else:
|
"SKILL.md file, and paste that URL; the repository-root link "
|
||||||
m = re.search(r"https?://github\.com/[^\s\"')]+", r.text or "")
|
"alone is not sufficient"
|
||||||
if m:
|
)
|
||||||
raw = m.group(0).rstrip(".,)")
|
url = final
|
||||||
|
|
||||||
parsed = urlparse(raw)
|
# Update parsed and hostname to reflect the new GitHub URL
|
||||||
host = _github_host(raw)
|
parsed = urlparse(url)
|
||||||
if host not in _GITHUB_HOSTS:
|
hostname = (parsed.hostname or "").lower()
|
||||||
raise SkillImportError(
|
|
||||||
"Only GitHub URLs are supported (https://github.com/... or raw.githubusercontent.com/...)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if host == "raw.githubusercontent.com":
|
_assert_github_url(url)
|
||||||
|
|
||||||
|
if hostname == "raw.githubusercontent.com":
|
||||||
# /owner/repo/ref/path/to/file
|
# /owner/repo/ref/path/to/file
|
||||||
bits = [p for p in parsed.path.split("/") if p]
|
bits = [p for p in parsed.path.split("/") if p]
|
||||||
if len(bits) < 4:
|
if len(bits) < 4:
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Skill URL importer — GitHub path parsing."""
|
"""Skill URL importer — GitHub path parsing."""
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from services.memory.skill_importer import (
|
from services.memory.skill_importer import (
|
||||||
@@ -11,6 +13,13 @@ from services.memory.skill_importer import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_fetch(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"services.memory.skill_importer._resolve_and_check_url",
|
||||||
|
lambda url: [ipaddress.ip_address("93.184.216.34")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_github_blob_skill_md():
|
def test_parse_github_blob_skill_md():
|
||||||
src = parse_skill_source(
|
src = parse_skill_source(
|
||||||
"https://github.com/anthropics/skills/blob/main/skills/pdf/SKILL.md"
|
"https://github.com/anthropics/skills/blob/main/skills/pdf/SKILL.md"
|
||||||
@@ -69,10 +78,7 @@ def test_fetch_bytes_rejects_cross_host_redirect(monkeypatch):
|
|||||||
return _Resp()
|
return _Resp()
|
||||||
|
|
||||||
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
|
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
|
||||||
monkeypatch.setattr(
|
_allow_fetch(monkeypatch)
|
||||||
"services.memory.skill_importer.check_outbound_url",
|
|
||||||
lambda url, **kwargs: (True, ""),
|
|
||||||
)
|
|
||||||
with pytest.raises(SkillImportError, match="redirect target"):
|
with pytest.raises(SkillImportError, match="redirect target"):
|
||||||
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")
|
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")
|
||||||
|
|
||||||
@@ -89,10 +95,7 @@ def test_list_github_dir_accepts_api_github_response(monkeypatch):
|
|||||||
"services.memory.skill_importer._fetch_text",
|
"services.memory.skill_importer._fetch_text",
|
||||||
lambda url: "# skill\n",
|
lambda url: "# skill\n",
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
_allow_fetch(monkeypatch)
|
||||||
"services.memory.skill_importer.check_outbound_url",
|
|
||||||
lambda url, **kwargs: (True, ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
class _Resp:
|
class _Resp:
|
||||||
url = "https://api.github.com/repos/o/r/contents?ref=main"
|
url = "https://api.github.com/repos/o/r/contents?ref=main"
|
||||||
@@ -144,10 +147,7 @@ def _mock_httpx_client(monkeypatch, response):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
|
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
|
||||||
monkeypatch.setattr(
|
_allow_fetch(monkeypatch)
|
||||||
"services.memory.skill_importer.check_outbound_url",
|
|
||||||
lambda url, **kwargs: (True, ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_github_dir_surfaces_rate_limit(monkeypatch):
|
def test_list_github_dir_surfaces_rate_limit(monkeypatch):
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Deterministic regressions for skill-import DNS validation and pinning."""
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import httpcore
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from services.memory import skill_importer
|
||||||
|
|
||||||
|
|
||||||
|
PUBLIC_A = ipaddress.ip_address("93.184.216.34")
|
||||||
|
PUBLIC_B = ipaddress.ip_address("1.1.1.1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_snapshot_is_the_only_connect_destination(monkeypatch):
|
||||||
|
answers = iter([
|
||||||
|
[str(PUBLIC_A)],
|
||||||
|
["127.0.0.1"],
|
||||||
|
])
|
||||||
|
resolver_calls = []
|
||||||
|
|
||||||
|
def _flipping_resolver(host):
|
||||||
|
resolver_calls.append(host)
|
||||||
|
return next(answers)
|
||||||
|
|
||||||
|
monkeypatch.setattr(skill_importer, "_default_resolver", _flipping_resolver)
|
||||||
|
pinned_ips = skill_importer._check_fetch_url("https://rebind.example/skill")
|
||||||
|
|
||||||
|
connected = []
|
||||||
|
|
||||||
|
class _RecordingBackend:
|
||||||
|
def connect_tcp(self, host, port, timeout, local_address, socket_options):
|
||||||
|
connected.append((host, port))
|
||||||
|
return object()
|
||||||
|
|
||||||
|
backend = skill_importer._PinnedBackend(pinned_ips)
|
||||||
|
backend._real = _RecordingBackend()
|
||||||
|
backend.connect_tcp("rebind.example", 443, timeout=1.0)
|
||||||
|
|
||||||
|
assert resolver_calls == ["rebind.example"]
|
||||||
|
assert pinned_ips == [PUBLIC_A]
|
||||||
|
assert connected == [(str(PUBLIC_A), 443)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_backend_falls_back_only_within_validated_snapshot():
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
class _FallbackBackend:
|
||||||
|
def connect_tcp(self, host, port, timeout, local_address, socket_options):
|
||||||
|
attempts.append((host, timeout))
|
||||||
|
if host == str(PUBLIC_A):
|
||||||
|
raise httpcore.ConnectError("first address unavailable")
|
||||||
|
return "connected"
|
||||||
|
|
||||||
|
backend = skill_importer._PinnedBackend([PUBLIC_A, PUBLIC_B])
|
||||||
|
backend._real = _FallbackBackend()
|
||||||
|
|
||||||
|
assert backend.connect_tcp("rebind.example", 443, timeout=1.0) == "connected"
|
||||||
|
assert [host for host, _ in attempts] == [str(PUBLIC_A), str(PUBLIC_B)]
|
||||||
|
assert all(timeout is not None and 0 <= timeout <= 1.0 for _, timeout in attempts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transport_preserves_request_authority_and_response_url():
|
||||||
|
recorded = []
|
||||||
|
|
||||||
|
class _CoreResponse:
|
||||||
|
status = 200
|
||||||
|
headers = [(b"content-type", b"text/plain")]
|
||||||
|
stream = [b"ok"]
|
||||||
|
extensions = {}
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _RecordingPool:
|
||||||
|
def handle_request(self, request):
|
||||||
|
recorded.append(request)
|
||||||
|
return _CoreResponse()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
transport = skill_importer._PinnedTransport([PUBLIC_A])
|
||||||
|
transport._pool.close()
|
||||||
|
transport._pool = _RecordingPool()
|
||||||
|
|
||||||
|
url = "https://github.com:444/octocat/repo?q=1"
|
||||||
|
with httpx.Client(transport=transport) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
|
||||||
|
core_request = recorded[0]
|
||||||
|
assert core_request.url.host == b"github.com"
|
||||||
|
assert core_request.url.port == 444
|
||||||
|
assert core_request.url.target == b"/octocat/repo?q=1"
|
||||||
|
assert (b"host", b"github.com:444") in [
|
||||||
|
(name.lower(), value) for name, value in core_request.headers
|
||||||
|
]
|
||||||
|
assert str(response.url) == url
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_checked_uses_fresh_transport_per_redirect_hop(monkeypatch):
|
||||||
|
first = "https://github.com/owner/repo"
|
||||||
|
second = "https://raw.githubusercontent.com/owner/repo/main/SKILL.md"
|
||||||
|
snapshots = {
|
||||||
|
first: [PUBLIC_A],
|
||||||
|
second: [PUBLIC_B],
|
||||||
|
}
|
||||||
|
clients = []
|
||||||
|
requested = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
skill_importer,
|
||||||
|
"_resolve_and_check_url",
|
||||||
|
lambda url: snapshots[url],
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, *, transport, follow_redirects, timeout):
|
||||||
|
assert follow_redirects is False
|
||||||
|
clients.append((transport._pinned_ips, timeout))
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get(self, url, headers=None):
|
||||||
|
requested.append((url, headers))
|
||||||
|
request = httpx.Request("GET", url)
|
||||||
|
if url == first:
|
||||||
|
return httpx.Response(
|
||||||
|
302,
|
||||||
|
headers={"location": second},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return httpx.Response(200, content=b"ok", request=request)
|
||||||
|
|
||||||
|
monkeypatch.setattr(skill_importer.httpx, "Client", _Client)
|
||||||
|
|
||||||
|
response = skill_importer._get_checked(first, headers={"Accept": "text/plain"})
|
||||||
|
|
||||||
|
assert [ips for ips, _ in clients] == [[PUBLIC_A], [PUBLIC_B]]
|
||||||
|
assert requested == [
|
||||||
|
(first, {"Accept": "text/plain"}),
|
||||||
|
(second, {"Accept": "text/plain"}),
|
||||||
|
]
|
||||||
|
assert str(response.url) == second
|
||||||
|
|
||||||
|
|
||||||
|
def test_dns_rebinding_pinned_transport_dials_pinned_ip():
|
||||||
|
"""The real pool must dial the pinned IP and keep the logical request intact.
|
||||||
|
|
||||||
|
Everything above this test replaces the pool or the backend, so this is the
|
||||||
|
only case that exercises ``httpcore.ConnectionPool`` end to end: the socket
|
||||||
|
goes to the pinned address while URL, ``Host``, and the decoded body stay on
|
||||||
|
the original hostname.
|
||||||
|
"""
|
||||||
|
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
server_socket.bind(("127.0.0.1", 0))
|
||||||
|
server_socket.listen(1)
|
||||||
|
port = server_socket.getsockname()[1]
|
||||||
|
|
||||||
|
captured_request = b""
|
||||||
|
client_address = None
|
||||||
|
server_error = None
|
||||||
|
|
||||||
|
def handle_client():
|
||||||
|
nonlocal captured_request, client_address, server_error
|
||||||
|
try:
|
||||||
|
conn, client_address = server_socket.accept()
|
||||||
|
with conn:
|
||||||
|
conn.settimeout(2.0)
|
||||||
|
while b"\r\n\r\n" not in captured_request:
|
||||||
|
if len(captured_request) >= 16_384:
|
||||||
|
raise AssertionError("request headers exceeded 16 KiB")
|
||||||
|
chunk = conn.recv(min(4096, 16_384 - len(captured_request)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
captured_request += chunk
|
||||||
|
if b"\r\n\r\n" not in captured_request:
|
||||||
|
raise AssertionError(
|
||||||
|
"connection closed before request headers completed"
|
||||||
|
)
|
||||||
|
body = gzip.compress(b"successfully decoded gzip body")
|
||||||
|
conn.sendall(
|
||||||
|
b"HTTP/1.1 200 OK\r\n"
|
||||||
|
b"Content-Encoding: gzip\r\n"
|
||||||
|
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
|
||||||
|
b"\r\n" + body
|
||||||
|
)
|
||||||
|
except Exception as exc: # surfaced by the assertion below
|
||||||
|
server_error = exc
|
||||||
|
|
||||||
|
server_thread = threading.Thread(target=handle_client, daemon=True)
|
||||||
|
server_thread.start()
|
||||||
|
|
||||||
|
# Any hostname works: only the pinned snapshot decides where the socket goes.
|
||||||
|
url = f"http://rebind.example:{port}/secret-metadata"
|
||||||
|
try:
|
||||||
|
with httpx.Client(
|
||||||
|
transport=skill_importer._PinnedTransport([ipaddress.ip_address("127.0.0.1")])
|
||||||
|
) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
server_thread.join(timeout=5.0)
|
||||||
|
finally:
|
||||||
|
server_socket.close()
|
||||||
|
|
||||||
|
assert server_error is None, server_error
|
||||||
|
assert not server_thread.is_alive()
|
||||||
|
assert client_address is not None and client_address[0] == "127.0.0.1"
|
||||||
|
assert f"host: rebind.example:{port}".encode() in captured_request.lower()
|
||||||
|
assert str(response.url) == url
|
||||||
|
assert response.text == "successfully decoded gzip body"
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import re
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.memory.skill_importer import (
|
||||||
|
ResolvedSource,
|
||||||
|
SkillImportError,
|
||||||
|
check_outbound_url,
|
||||||
|
parse_skill_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
## 1. Tests for Hostname Dispatch & Substring Spoofing
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
[
|
||||||
|
"https://skills.sh.attacker.com/owner/repo",
|
||||||
|
"https://evilskills.sh/owner/repo",
|
||||||
|
"https://notskills.sh/owner/repo",
|
||||||
|
"https://api.skills.sh/owner/repo",
|
||||||
|
"https://1.1.1.1/skills.sh/owner/repo",
|
||||||
|
"http://localhost/skills.sh/owner/repo",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_skill_source_rejects_unsupported_host_before_fetch(url):
|
||||||
|
"""Unsupported authorities must never reach the network unwrap path."""
|
||||||
|
with patch("services.memory.skill_importer._get_checked") as mock_get:
|
||||||
|
with pytest.raises(SkillImportError):
|
||||||
|
parse_skill_source(url)
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("entry", ["https://skills.sh/my-skill", "https://www.skills.sh/my-skill"])
|
||||||
|
def test_parse_skill_source_unwraps_skills_sh_redirect_to_github(entry):
|
||||||
|
"""Both skills.sh spellings unwrap when the fetch lands on a GitHub host."""
|
||||||
|
with patch("services.memory.skill_importer._get_checked") as mock_get:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.url = "https://github.com/test-owner/test-repo"
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
source = parse_skill_source(entry)
|
||||||
|
assert source.owner == "test-owner"
|
||||||
|
assert source.repo == "test-repo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_skill_source_rejects_skills_sh_page_that_never_reaches_github():
|
||||||
|
"""A skills.sh page that does not redirect to GitHub must fail loudly.
|
||||||
|
|
||||||
|
The live site serves the skill page from ``www.skills.sh`` and only ever
|
||||||
|
links the repository root, never the skill's subdirectory. Scraping a
|
||||||
|
``github.com`` link out of the body therefore resolves every skill in a
|
||||||
|
repo to the same bundle, so the importer must refuse rather than guess.
|
||||||
|
"""
|
||||||
|
body = '<html><body><a href="https://github.com/test-owner/test-repo">Repository</a></body></html>'
|
||||||
|
with patch("services.memory.skill_importer._get_checked") as mock_get:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.url = "https://www.skills.sh/anthropics/skills/pdf"
|
||||||
|
mock_response.text = body
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
SkillImportError, match="did not redirect to GitHub"
|
||||||
|
) as exc_info:
|
||||||
|
parse_skill_source("https://skills.sh/anthropics/skills/pdf")
|
||||||
|
|
||||||
|
message = str(exc_info.value)
|
||||||
|
assert "exact skill folder or SKILL.md file" in message
|
||||||
|
assert "repository-root link alone is not sufficient" in message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("url", "expected"),
|
||||||
|
[
|
||||||
|
("ftp://github.com/o/r", "unsupported URL scheme: ftp"),
|
||||||
|
("file:///etc/passwd", "unsupported URL scheme: file"),
|
||||||
|
("gopher://github.com/o/r", "unsupported URL scheme: gopher"),
|
||||||
|
("javascript:alert(1)", "Only GitHub or skills.sh URLs are supported"),
|
||||||
|
("mailto:x@y.z", "Only GitHub or skills.sh URLs are supported"),
|
||||||
|
("data:text/html,<b>x</b>", "Only GitHub or skills.sh URLs are supported"),
|
||||||
|
("https://evil.example/o/r", "Only GitHub or skills.sh URLs are supported"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_skill_source_reports_the_real_reason_for_rejection(url, expected):
|
||||||
|
"""A supplied-but-unusable URL must not be reported as a missing URL."""
|
||||||
|
with patch("services.memory.skill_importer._get_checked") as mock_get:
|
||||||
|
with pytest.raises(SkillImportError, match=re.escape(expected)):
|
||||||
|
parse_skill_source(url)
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("url", "owner", "repo"),
|
||||||
|
[
|
||||||
|
("github.com/octocat/Hello-World", "octocat", "Hello-World"),
|
||||||
|
("HTTPS://github.com/octocat/Hello-World", "octocat", "Hello-World"),
|
||||||
|
("github.com:443/octocat/Hello-World", "octocat", "Hello-World"),
|
||||||
|
("github.com/octocat/Hello-World?q=a://b", "octocat", "Hello-World"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_skill_source_accepts_schemeless_and_uppercase_github(url, owner, repo):
|
||||||
|
"""A schemeless host, an uppercase scheme, and a ``://`` in the query all parse."""
|
||||||
|
source = parse_skill_source(url)
|
||||||
|
assert (source.owner, source.repo) == (owner, repo)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_skill_source_valid_github():
|
||||||
|
"""Ensure standard GitHub URLs parse into the correct ResolvedSource fields."""
|
||||||
|
source = parse_skill_source("https://github.com/octocat/Hello-World/tree/main/docs")
|
||||||
|
assert isinstance(source, ResolvedSource)
|
||||||
|
assert source.owner == "octocat"
|
||||||
|
assert source.repo == "Hello-World"
|
||||||
|
assert source.ref == "main"
|
||||||
|
assert source.path == "docs"
|
||||||
|
|
||||||
|
|
||||||
|
## 2. Tests for SSRF Guard & CGNAT
|
||||||
|
|
||||||
|
def test_check_outbound_url_blocks_cgnat():
|
||||||
|
"""Ensure Carrier-Grade NAT (RFC 6598) block 100.64.0.0/10 is blocked."""
|
||||||
|
def mock_resolver(host):
|
||||||
|
return ["100.64.5.10"]
|
||||||
|
|
||||||
|
ok, reason = check_outbound_url("http://example.com", block_private=True, resolver=mock_resolver)
|
||||||
|
assert not ok
|
||||||
|
assert "private/shared/loopback" in reason # Updated to match your codebase's error string
|
||||||
|
|
||||||
|
def test_check_outbound_url_blocks_loopback():
|
||||||
|
"""Ensure loopback IPs (127.0.0.1) are blocked by default."""
|
||||||
|
def mock_resolver(host):
|
||||||
|
return ["127.0.0.1"]
|
||||||
|
|
||||||
|
ok, reason = check_outbound_url("http://localhost", block_private=True, resolver=mock_resolver)
|
||||||
|
assert not ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_outbound_url_blocks_metadata():
|
||||||
|
"""Ensure cloud metadata endpoints (169.254.169.254) are blocked."""
|
||||||
|
def mock_resolver(host):
|
||||||
|
return ["169.254.169.254"]
|
||||||
|
|
||||||
|
ok, reason = check_outbound_url("http://metadata.google.internal", block_private=True, resolver=mock_resolver)
|
||||||
|
assert not ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_outbound_url_allows_public_ip():
|
||||||
|
"""Ensure public routable IPs pass successfully."""
|
||||||
|
def mock_resolver(host):
|
||||||
|
return ["93.184.216.34"]
|
||||||
|
|
||||||
|
ok, reason = check_outbound_url("http://example.com", block_private=True, resolver=mock_resolver)
|
||||||
|
assert ok
|
||||||
|
assert reason == "ok"
|
||||||
@@ -6,10 +6,12 @@ path in ``services/search/content.py:_get_public_url``. Previously it used
|
|||||||
``httpx``'s ``follow_redirects=True`` with the lenient guard on the *initial*
|
``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.
|
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``
|
These tests are hermetic: public and internal guard cases use IP literals, while
|
||||||
resolves them locally (``getaddrinfo`` on a numeric address does no DNS) and no
|
the exact ``skills.sh`` case injects its validated address snapshot. The HTTP
|
||||||
network access is required. The HTTP layer is faked so no real request is made.
|
layer is faked, so no real DNS lookup or request is made.
|
||||||
"""
|
"""
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from services.memory import skill_importer
|
from services.memory import skill_importer
|
||||||
@@ -108,10 +110,20 @@ def test_fetch_bytes_blocks_redirect_to_internal(monkeypatch, internal):
|
|||||||
|
|
||||||
def test_skills_sh_entry_blocks_redirect_to_metadata(monkeypatch):
|
def test_skills_sh_entry_blocks_redirect_to_metadata(monkeypatch):
|
||||||
# The skills.sh unwrap path (user-supplied host) must also revalidate hops.
|
# 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"
|
raw = "https://skills.sh/example/skill"
|
||||||
|
checked = []
|
||||||
|
|
||||||
|
def _check_hop(url):
|
||||||
|
checked.append(url)
|
||||||
|
if url == raw:
|
||||||
|
return [ipaddress.ip_address("1.1.1.1")]
|
||||||
|
raise SkillImportError("outbound URL blocked: private target")
|
||||||
|
|
||||||
|
monkeypatch.setattr(skill_importer, "_resolve_and_check_url", _check_hop)
|
||||||
_install_fake_client(monkeypatch, redirect_from=raw, redirect_to=METADATA)
|
_install_fake_client(monkeypatch, redirect_from=raw, redirect_to=METADATA)
|
||||||
with pytest.raises(SkillImportError, match="blocked"):
|
with pytest.raises(SkillImportError, match="blocked"):
|
||||||
parse_skill_source(raw)
|
parse_skill_source(raw)
|
||||||
|
assert checked == [raw, METADATA]
|
||||||
|
|
||||||
|
|
||||||
# --- Positive: a legitimate public->public redirect is still followed --------
|
# --- Positive: a legitimate public->public redirect is still followed --------
|
||||||
|
|||||||
Reference in New Issue
Block a user