{"record":{"id":"2575b9c18aecefed","repo":"odysseus-dev/odysseus","slug":"outbound-url-blocked-reason","errorCode":null,"errorMessage":"outbound URL blocked: {reason}","messagePattern":"outbound URL blocked: (.+?)","errorType":"validation","errorClass":"SkillImportError","httpStatus":null,"severity":"critical","filePath":"services/memory/skill_importer.py","lineNumber":111,"sourceCode":"    return ips\n\n\ndef _resolve_and_check_url(url: str) -> List[ipaddress._BaseAddress]:\n    \"\"\"Return the exact address snapshot approved for one fetch hop.\"\"\"\n    resolved_ips: List[str] = []\n\n    def _recording_resolver(host: str) -> List[str]:\n        answers = list(_default_resolver(host))\n        resolved_ips[:] = answers\n        return answers\n\n    ok, reason = check_outbound_url(\n        url,\n        block_private=True,\n        resolver=_recording_resolver,\n    )\n    if not ok:\n        raise SkillImportError(f\"outbound URL blocked: {reason}\")\n\n    pinned_ips = _validated_ips(resolved_ips)\n    if not pinned_ips:\n        raise SkillImportError(\"outbound URL blocked: host did not resolve to a usable address\")\n    return pinned_ips\n\n\n# Backward compatibility alias for tests importing _check_fetch_url directly\n_check_fetch_url = _resolve_and_check_url\n\n\nclass _PinnedBackend(httpcore.NetworkBackend):\n    \"\"\"Connect only to addresses from one validated DNS snapshot.\"\"\"\n\n    def __init__(self, ips: List[ipaddress._BaseAddress]):\n        self._ips = [str(ip) for ip in ips]\n        self._real = httpcore.SyncBackend()\n","sourceCodeStart":93,"sourceCodeEnd":129,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/services/memory/skill_importer.py#L93-L129","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# before\nimport_skill(\"https://skills.sh/x/y\")  # redirect chain hits http://169.254.169.254/...\nSkillImportError: outbound URL blocked: host resolves to private address 169.254.169.254\n\n# after\nimport_skill(\"https://github.com/owner/repo/tree/main/skills/y\")  # direct GitHub URL, no risky hops","handlingStrategy":"validation","validationCode":"import ipaddress\nfrom urllib.parse import urlparse\n\ndef is_public_https_url(url: str) -> bool:\n    p = urlparse(url)\n    if p.scheme not in ('http', 'https') or not p.hostname:\n        return False\n    try:\n        infos = socket.getaddrinfo(p.hostname, None)\n    except socket.gaierror:\n        return False\n    for info in infos:\n        ip = ipaddress.ip_address(info[4][0])\n        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:\n            return False\n    return True","typeGuard":"def is_fetchable_public_url(url: str) -> bool:\n    try:\n        return is_public_https_url(url)\n    except ValueError:\n        return False","tryCatchPattern":"from services.memory.skill_importer import SkillImportError\n\ntry:\n    import_skill(url)\nexcept SkillImportError as e:\n    if 'outbound URL blocked' in str(e):\n        raise UserFacingError('This skill URL points at a blocked/private address. Use the canonical GitHub URL.') from e\n    raise","preventionTips":["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."],"tags":["security","ssrf","network","dns","skill-import"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}