Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f09d20c
fix(skill-importer): validate URL scheme and improve skills.sh handling
bitboody Aug 11, 2026
bf42c5b
fix(skill-importer): enhance DNS resolution and SSRF protection in fe…
bitboody Aug 11, 2026
e47e4b7
fix(url-safety): add allowed_dist parameter to check_outbound_url for…
bitboody Aug 11, 2026
39f01a4
test(skill-importer): add comprehensive tests for URL parsing and out…
bitboody Aug 11, 2026
b61e03c
ensure newline at end of file in test_check_outbound_url_allows_publi…
bitboody Aug 11, 2026
e857bfd
fix(skill-importer): improve TLS certificate handling in _get_checked…
bitboody Aug 11, 2026
2c822ea
fix(skill-importer): enhance _check_fetch_url to handle both hostname…
bitboody Aug 11, 2026
1c58b92
fix(skill-importer): enhance parse_skill_source to support skills.sh …
bitboody Aug 11, 2026
841a5da
fix(skill-importer): simplify skills.sh hostname check in parse_skill…
bitboody Aug 11, 2026
ab3f39f
fix(skill-importer): enhance parse_skill_source to identify skills.sh…
bitboody Aug 11, 2026
a4fb857
fix(skill-importer): enhance _resolve_and_check_url to validate all r…
bitboody Aug 11, 2026
82bba2b
fix(skill-importer): enhance parse_skill_source to support schemeless…
bitboody Aug 11, 2026
e6a374f
fix(memory): resolve CodeQL URL sanitization warning and restore _che…
bitboody Aug 11, 2026
9dc2e87
fix(memory): pin skill fetch sockets without rewriting URLs
RaresKeY Aug 11, 2026
3fb472a
fix(memory): reject unsupported skill wrapper hosts
RaresKeY Aug 11, 2026
ce24f5e
refactor(url-safety): remove unused importer exception
RaresKeY Aug 11, 2026
b1a17ad
test(memory): keep redirect regression hermetic
RaresKeY Aug 11, 2026
c0ff885
test(dns-rebinding): add test for _PinnedTransport to ensure connecti…
bitboody Aug 12, 2026
8ae35c7
fix(skill-importer): enhance skills.sh support to extract GitHub link…
bitboody Aug 12, 2026
1338ee4
fix(skill-importer): improve URL scheme validation for GitHub and ski…
bitboody Aug 12, 2026
da8c379
fix(skills): reject unusable skill URLs instead of guessing
o3LL Aug 14, 2026
ae70fef
test(skills): tighten the real-socket pinning regression
o3LL Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 206 additions & 42 deletions services/memory/skill_importer.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
from __future__ import annotations

import ipaddress
import logging
import os
import re
import time
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

import httpcore
import httpx

from src.url_safety import check_outbound_url
from src.url_safety import _default_resolver, check_outbound_url

logger = logging.getLogger(__name__)

Expand All @@ -25,6 +27,7 @@
_GITHUB_HOSTS = frozenset({
"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com",
})
_SKILLS_SH_HOSTS = frozenset({"skills.sh"})


def _github_host(url: str) -> str:
Expand Down Expand Up @@ -72,18 +75,158 @@ def _is_text_file(name: str) -> bool:
_MAX_FETCH_REDIRECTS = 5


def _check_fetch_url(url: str) -> None:
"""SSRF guard for skill-import fetches (defense-in-depth).
def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
"""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 —
matching the hardened web-fetch path in
``services/search/content.py:_get_public_url`` rather than the lenient
default used for admin-configured model endpoints.
"""
ok, reason = check_outbound_url(url, block_private=True)

def _resolve_and_check_url(url: str) -> List[ipaddress._BaseAddress]:
"""Return the exact address snapshot approved for one fetch hop."""
resolved_ips: List[str] = []

def _recording_resolver(host: str) -> List[str]:
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:
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(
Expand All @@ -100,49 +243,70 @@ def _get_checked(
hand lets us re-validate every hop, closing that blind-SSRF gap.
"""
current = url
with httpx.Client(follow_redirects=False, timeout=timeout) as client:
for _ in range(_MAX_FETCH_REDIRECTS + 1):
_check_fetch_url(current)
for _ in range(_MAX_FETCH_REDIRECTS + 1):
pinned_ips = _resolve_and_check_url(current)
with httpx.Client(
transport=_PinnedTransport(pinned_ips),
follow_redirects=False,
timeout=timeout,
) as client:
r = client.get(current, headers=headers)
if r.status_code in (301, 302, 303, 307, 308):
location = r.headers.get("location")
if not location:
return r
current = urljoin(str(r.url), location)
continue
return r

if r.status_code in (301, 302, 303, 307, 308):
location = r.headers.get("location")
if not location:
return r
current = urljoin(str(r.url), location)
continue
return r
raise SkillImportError("too many redirects while fetching skill bundle")


def parse_skill_source(url: str) -> ResolvedSource:
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
raw = (url or "").strip()
if not raw:
url = (url or "").strip()
if not url:
raise SkillImportError("URL is required")

# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
if "skills.sh" in raw and "github.com" not in raw:
r = _get_checked(raw, timeout=20.0)
# Support backwards compatibility for schemeless GitHub or skills.sh paths.
if not url.startswith(("http://", "https://")):
parsed_rough = urlparse("//" + url)
hostname = (parsed_rough.hostname or "").lower()

if hostname in _GITHUB_HOSTS or hostname in _SKILLS_SH_HOSTS:
url = "https://" + url
else:
if parsed_rough.scheme:
raise SkillImportError(f"unsupported URL scheme: {parsed_rough.scheme}")
else:
raise SkillImportError("URL is required")

parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise SkillImportError(f"unsupported URL scheme: {parsed.scheme}")

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"
)

# skills.sh links must resolve to an exact supported GitHub host.
if hostname in _SKILLS_SH_HOSTS:
r = _get_checked(url, timeout=20.0)
if r.status_code >= 400:
raise _github_response_error(r)
final = str(r.url)
_assert_github_url(final, context="redirect target")
# Page may embed a github link; prefer final URL if redirected.
if "github.com" in final:
raw = final
else:
m = re.search(r"https?://github\.com/[^\s\"')]+", r.text or "")
if m:
raw = m.group(0).rstrip(".,)")
url = final

parsed = urlparse(raw)
host = _github_host(raw)
if host not in _GITHUB_HOSTS:
raise SkillImportError(
"Only GitHub URLs are supported (https://github.com/... or raw.githubusercontent.com/...)"
)
# Update parsed and hostname to reflect the new GitHub URL
parsed = urlparse(url)
hostname = (parsed.hostname or "").lower()

_assert_github_url(url)

if host == "raw.githubusercontent.com":
if hostname == "raw.githubusercontent.com":
# /owner/repo/ref/path/to/file
bits = [p for p in parsed.path.split("/") if p]
if len(bits) < 4:
Expand Down
24 changes: 12 additions & 12 deletions tests/test_skill_importer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Skill URL importer — GitHub path parsing."""
import ipaddress

import pytest

from services.memory.skill_importer import (
Expand All @@ -11,6 +13,13 @@
)


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():
src = parse_skill_source(
"https://github.com/anthropics/skills/blob/main/skills/pdf/SKILL.md"
Expand Down Expand Up @@ -69,10 +78,7 @@ 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, ""),
)
_allow_fetch(monkeypatch)
with pytest.raises(SkillImportError, match="redirect target"):
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")

Expand All @@ -89,10 +95,7 @@ def test_list_github_dir_accepts_api_github_response(monkeypatch):
"services.memory.skill_importer._fetch_text",
lambda url: "# skill\n",
)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url, **kwargs: (True, ""),
)
_allow_fetch(monkeypatch)

class _Resp:
url = "https://api.github.com/repos/o/r/contents?ref=main"
Expand Down Expand Up @@ -144,10 +147,7 @@ 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, ""),
)
_allow_fetch(monkeypatch)


def test_list_github_dir_surfaces_rate_limit(monkeypatch):
Expand Down
Loading