mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-21 16:34:19 +02:00
* 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>
487 lines
17 KiB
Python
487 lines
17 KiB
Python
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import logging
|
|
import os
|
|
import time
|
|
from dataclasses import dataclass
|
|
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 _default_resolver, check_outbound_url
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_FILES = 64
|
|
MAX_TOTAL_BYTES = 2_000_000
|
|
MAX_FILE_BYTES = 400_000
|
|
ALLOWED_SUFFIXES = (
|
|
".md", ".txt", ".json", ".yaml", ".yml", ".py", ".sh", ".toml",
|
|
".js", ".ts", ".css", ".html", ".xml", ".csv",
|
|
)
|
|
TEXT_NAMES = {"skill.md", "license", "license.md", "readme.md"}
|
|
_GITHUB_HOSTS = frozenset({
|
|
"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:
|
|
return (urlparse(str(url)).hostname or "").lower()
|
|
|
|
|
|
def _assert_github_url(url: str, *, context: str = "URL") -> None:
|
|
host = _github_host(url)
|
|
if host not in _GITHUB_HOSTS:
|
|
raise SkillImportError(
|
|
f"{context} must stay on GitHub (got {host or 'unknown host'})"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class ResolvedSource:
|
|
owner: str
|
|
repo: str
|
|
ref: str
|
|
path: str # directory or file path inside repo (no leading slash)
|
|
|
|
|
|
class SkillImportError(ValueError):
|
|
pass
|
|
|
|
|
|
def _safe_relpath(rel: str) -> str:
|
|
rel = (rel or "").replace("\\", "/").strip().lstrip("/")
|
|
if not rel or rel.startswith("..") or "/../" in f"/{rel}/":
|
|
raise SkillImportError(f"unsafe path: {rel!r}")
|
|
parts = [p for p in rel.split("/") if p and p != "."]
|
|
if any(p == ".." for p in parts):
|
|
raise SkillImportError(f"unsafe path: {rel!r}")
|
|
return "/".join(parts)
|
|
|
|
|
|
def _is_text_file(name: str) -> bool:
|
|
low = name.lower()
|
|
if low in TEXT_NAMES:
|
|
return True
|
|
return any(low.endswith(s) for s in ALLOWED_SUFFIXES)
|
|
|
|
|
|
# Max redirect hops to follow manually while re-validating each one.
|
|
_MAX_FETCH_REDIRECTS = 5
|
|
|
|
|
|
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
|
|
|
|
|
|
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(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(
|
|
url: str,
|
|
*,
|
|
headers: Optional[dict] = None,
|
|
timeout: float = 30.0,
|
|
) -> httpx.Response:
|
|
"""GET that follows redirects manually, re-running the SSRF guard per hop.
|
|
|
|
``httpx``'s ``follow_redirects=True`` validates only the initial URL, so a
|
|
``3xx`` to an internal address (``169.254.169.254``, ``127.0.0.1``, …) would
|
|
still be connected to before any post-hoc host check. Following redirects by
|
|
hand lets us re-validate every hop, closing that blind-SSRF gap.
|
|
"""
|
|
current = url
|
|
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
|
|
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."""
|
|
url = (url or "").strip()
|
|
if not url:
|
|
raise SkillImportError("URL is required")
|
|
|
|
# ``urlparse`` only reports an unambiguous scheme when the URL carries the
|
|
# ``scheme://`` form. Opaque schemes (``mailto:``, ``javascript:``) and a
|
|
# 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:
|
|
raise _github_response_error(r)
|
|
final = str(r.url)
|
|
if _github_host(final) not in _GITHUB_HOSTS:
|
|
raise SkillImportError(
|
|
"skills.sh did not redirect to GitHub — open the skill's "
|
|
"repository on GitHub, navigate to the exact skill folder or "
|
|
"SKILL.md file, and paste that URL; the repository-root link "
|
|
"alone is not sufficient"
|
|
)
|
|
url = final
|
|
|
|
# Update parsed and hostname to reflect the new GitHub URL
|
|
parsed = urlparse(url)
|
|
hostname = (parsed.hostname or "").lower()
|
|
|
|
_assert_github_url(url)
|
|
|
|
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:
|
|
raise SkillImportError("Invalid raw GitHub URL")
|
|
owner, repo, ref = bits[0], bits[1], bits[2]
|
|
path = "/".join(bits[3:])
|
|
return ResolvedSource(owner=owner, repo=repo, ref=ref, path=path)
|
|
|
|
bits = [p for p in parsed.path.split("/") if p]
|
|
if len(bits) < 2:
|
|
raise SkillImportError("Invalid GitHub URL")
|
|
owner, repo = bits[0], bits[1]
|
|
ref = "main"
|
|
path = ""
|
|
|
|
if len(bits) >= 4 and bits[2] in ("tree", "blob"):
|
|
ref = bits[3]
|
|
path = "/".join(bits[4:])
|
|
elif len(bits) == 2:
|
|
path = ""
|
|
else:
|
|
raise SkillImportError("GitHub URL must include /tree/<branch>/... or /blob/<branch>/...")
|
|
|
|
return ResolvedSource(owner=owner, repo=repo, ref=ref, path=path)
|
|
|
|
|
|
def _raw_url(src: ResolvedSource, rel_path: str) -> str:
|
|
rel = _safe_relpath(rel_path)
|
|
return f"https://raw.githubusercontent.com/{src.owner}/{src.repo}/{quote(src.ref, safe='')}/{quote(rel, safe='/')}"
|
|
|
|
|
|
def _api_contents_url(src: ResolvedSource, rel_path: str = "") -> str:
|
|
rel = _safe_relpath(rel_path) if rel_path else ""
|
|
base = f"https://api.github.com/repos/{src.owner}/{src.repo}/contents"
|
|
if rel:
|
|
base += f"/{quote(rel, safe='/')}"
|
|
return f"{base}?ref={quote(src.ref, safe='')}"
|
|
|
|
|
|
def _github_response_error(response: httpx.Response) -> SkillImportError:
|
|
"""Turn a failed GitHub HTTP response into a user-visible import error."""
|
|
status = response.status_code
|
|
detail = ""
|
|
try:
|
|
body = response.json()
|
|
if isinstance(body, dict):
|
|
detail = str(body.get("message") or "").strip()
|
|
except Exception:
|
|
detail = (response.text or "").strip()[:200]
|
|
|
|
low = detail.lower()
|
|
if status == 403 and "rate limit" in low:
|
|
return SkillImportError(
|
|
"GitHub API rate limit exceeded — try again in a bit"
|
|
+ (f" ({detail})" if detail else "")
|
|
)
|
|
if status == 404:
|
|
return SkillImportError("path not found on GitHub")
|
|
if detail:
|
|
return SkillImportError(f"GitHub request failed ({status}): {detail}")
|
|
return SkillImportError(f"GitHub request failed ({status})")
|
|
|
|
|
|
def _fetch_bytes(url: str) -> bytes:
|
|
r = _get_checked(url, headers={"Accept": "application/vnd.github+json"}, timeout=30.0)
|
|
if r.status_code >= 400:
|
|
raise _github_response_error(r)
|
|
_assert_github_url(str(r.url), context="redirect target")
|
|
if len(r.content) > MAX_FILE_BYTES:
|
|
raise SkillImportError(f"file too large: {url}")
|
|
return r.content
|
|
|
|
|
|
def _fetch_text(url: str) -> str:
|
|
data = _fetch_bytes(url)
|
|
try:
|
|
return data.decode("utf-8")
|
|
except UnicodeDecodeError as e:
|
|
raise SkillImportError(f"non-text file: {url}") from e
|
|
|
|
|
|
def _list_github_dir(src: ResolvedSource, rel_dir: str, out: Dict[str, str], *, depth: int = 0) -> None:
|
|
if depth > 4 or len(out) >= MAX_FILES:
|
|
return
|
|
url = _api_contents_url(src, rel_dir)
|
|
r = _get_checked(url, headers={"Accept": "application/vnd.github+json"}, timeout=30.0)
|
|
if r.status_code >= 400:
|
|
raise _github_response_error(r)
|
|
_assert_github_url(str(r.url), context="redirect target")
|
|
entries = r.json()
|
|
if not isinstance(entries, list):
|
|
raise SkillImportError("expected a directory on GitHub")
|
|
total = sum(len(v.encode("utf-8")) for v in out.values())
|
|
for ent in entries:
|
|
if len(out) >= MAX_FILES or total >= MAX_TOTAL_BYTES:
|
|
break
|
|
if not isinstance(ent, dict):
|
|
continue
|
|
name = ent.get("name") or ""
|
|
ent_type = ent.get("type")
|
|
rel = _safe_relpath(f"{rel_dir}/{name}" if rel_dir else name)
|
|
if ent_type == "dir":
|
|
_list_github_dir(src, rel, out, depth=depth + 1)
|
|
total = sum(len(v.encode("utf-8")) for v in out.values())
|
|
continue
|
|
if ent_type != "file" or not _is_text_file(name):
|
|
continue
|
|
dl = ent.get("download_url")
|
|
if not dl:
|
|
continue
|
|
_assert_github_url(dl, context="download URL")
|
|
text = _fetch_text(dl)
|
|
total += len(text.encode("utf-8"))
|
|
if total > MAX_TOTAL_BYTES:
|
|
raise SkillImportError("skill bundle exceeds size limit")
|
|
out[rel] = text
|
|
|
|
|
|
def fetch_skill_bundle(url: str) -> Tuple[Dict[str, str], ResolvedSource]:
|
|
"""Download SKILL.md and sibling text assets. Returns relative_path → content."""
|
|
src = parse_skill_source(url)
|
|
files: Dict[str, str] = {}
|
|
|
|
path = _safe_relpath(src.path) if src.path else ""
|
|
if path.lower().endswith("skill.md"):
|
|
files[path] = _fetch_text(_raw_url(src, path))
|
|
parent = "/".join(path.split("/")[:-1])
|
|
if parent:
|
|
try:
|
|
_list_github_dir(src, parent, files)
|
|
except SkillImportError:
|
|
pass
|
|
return files, src
|
|
|
|
if path:
|
|
try:
|
|
_fetch_text(_raw_url(src, f"{path}/SKILL.md"))
|
|
_list_github_dir(src, path, files)
|
|
return files, src
|
|
except Exception:
|
|
pass
|
|
try:
|
|
text = _fetch_text(_raw_url(src, path))
|
|
if path.lower().endswith(".md"):
|
|
files[path] = text
|
|
return files, src
|
|
except Exception:
|
|
pass
|
|
_list_github_dir(src, path, files)
|
|
else:
|
|
_list_github_dir(src, "", files)
|
|
|
|
if not any(p.lower().endswith("skill.md") for p in files):
|
|
# Flat repo root with SKILL.md only
|
|
try:
|
|
files["SKILL.md"] = _fetch_text(_raw_url(src, "SKILL.md"))
|
|
except Exception as e:
|
|
raise SkillImportError(
|
|
"No SKILL.md found — link to a skill folder or SKILL.md on GitHub"
|
|
) from e
|
|
return files, src
|
|
|
|
|
|
def pick_skill_md(files: Dict[str, str]) -> Tuple[str, str]:
|
|
for rel, content in files.items():
|
|
if rel.lower().endswith("skill.md"):
|
|
return rel, content
|
|
raise SkillImportError("bundle has no SKILL.md")
|
|
|
|
|
|
def default_category_from_source(src: ResolvedSource) -> str:
|
|
return "imported"
|