odysseus-dev/odysseus · critical · SkillImportError
outbound URL blocked: {reason}
Error message
outbound URL blocked: {reason} What it means
Raised in _resolve_and_check_url (services/memory/skill_importer.py) when check_outbound_url rejects the URL for an SSRF-related reason (with block_private=True): the host resolves to (or the URL literal is) a private/loopback/link-local/reserved address, the scheme is not fetchable, or the URL is malformed. The specific reason string from the checker is embedded. Only fetches that pass this gate proceed, and DNS answers are recorded so the connection can be pinned to the validated IPs.
Source
Thrown at services/memory/skill_importer.py:111
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()
View on GitHub (pinned to f9235ebbf1)
Solutions
- Read the embedded reason — it names exactly which check failed (private IP, loopback, bad scheme, etc.).
- Only import skills whose fetch URLs stay on public GitHub hosts; re-import with the canonical github.com/raw.githubusercontent.com URL.
- If a legitimately public hostname resolves privately on your network (split-horizon/VPN DNS), fix the resolver config or import from a network where the host resolves publicly.
- Never weaken block_private — the block is the security boundary of the importer.
Example fix
# before
import_skill("https://skills.sh/x/y") # redirect chain hits http://169.254.169.254/...
SkillImportError: outbound URL blocked: host resolves to private address 169.254.169.254
# after
import_skill("https://github.com/owner/repo/tree/main/skills/y") # direct GitHub URL, no risky hops Defensive patterns
Strategy: validation
Validate before calling
import ipaddress
from urllib.parse import urlparse
def is_public_https_url(url: str) -> bool:
p = urlparse(url)
if p.scheme not in ('http', 'https') or not p.hostname:
return False
try:
infos = socket.getaddrinfo(p.hostname, None)
except socket.gaierror:
return False
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
return False
return True Type guard
def is_fetchable_public_url(url: str) -> bool:
try:
return is_public_https_url(url)
except ValueError:
return False Try / catch
from services.memory.skill_importer import SkillImportError
try:
import_skill(url)
except SkillImportError as e:
if 'outbound URL blocked' in str(e):
raise UserFacingError('This skill URL points at a blocked/private address. Use the canonical GitHub URL.') from e
raise Prevention
- Only import skills sourced from github.com/skills.sh URLs you navigated to yourself.
- Keep block_private enabled in any fetch helper you write; re-validate after every DNS lookup, not once.
- Beware corporate split-horizon DNS making public names resolve privately — test resolution before importing in new networks.
- Never paste URLs containing literal IPs or localhost from skill instructions.
When it happens
Trigger: Skill URL or a redirect hop pointing at 127.0.0.1, ::1, 169.254.169.254 (cloud metadata), 10.x/192.168.x/172.16-31.x, or 0.0.0.0; a hostname whose public-looking DNS A record actually resolves into RFC1918 space (DNS rebinding attempt); http:// URLs to internal service names like http://gateway or http://localhost:8080 embedded in a skill bundle.
Common situations: Local development where a skill references localhost for testing; importing a maliciously crafted skill that probes internal network/metadata services; corporate DNS wildcard zones resolving unknown hosts to an internal address; the skills.sh redirect chain passing through an internal name.
Related errors
- outbound URL blocked: host did not resolve to a usable addre
- {context} must stay on GitHub (got {host or 'unknown host'})
- too many redirects while fetching skill bundle
- unsafe path: {rel!r}
- unsupported URL scheme: {scheme}
AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14).
Data as JSON: /api/errors/2575b9c18aecefed.
Report an issue: GitHub.