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

  1. Read the embedded reason — it names exactly which check failed (private IP, loopback, bad scheme, etc.).
  2. Only import skills whose fetch URLs stay on public GitHub hosts; re-import with the canonical github.com/raw.githubusercontent.com URL.
  3. 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.
  4. 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

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


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/2575b9c18aecefed. Report an issue: GitHub.