{"record":{"id":"a132e6b7272b7267","repo":"iflytek/astron-agent","slug":"outbound-address-is-unsafe","errorCode":null,"errorMessage":"Outbound address is unsafe","messagePattern":"Outbound address is unsafe","errorType":"exception","errorClass":"OutboundPolicyError","httpStatus":null,"severity":"error","filePath":"core/plugin/link/infra/tool_exector/ssrf_guard.py","lineNumber":120,"sourceCode":"            and _endpoint(parsed) in self.allowed_private_endpoints\n        )\n\n    def validate_address(\n        self,\n        address: IpAddress,\n        *,\n        allow_private_endpoint: bool,\n        allow_literal_exception: bool,\n    ) -> None:\n        \"\"\"Validate the exact IP address that aiohttp is about to connect to.\"\"\"\n        if _matches_any(address, self.blocked_networks):\n            raise OutboundPolicyError(\"Outbound address is blocked\")\n        if allow_literal_exception and _matches_any(\n            address, self.allowed_literal_networks\n        ):\n            return\n        if _is_never_connect_address(address):\n            raise OutboundPolicyError(\"Outbound address is unsafe\")\n        if allow_private_endpoint:\n            return\n        if not _canonical_address(address).is_global:\n            raise OutboundPolicyError(\"Outbound address is not globally routable\")\n\n    def is_domain_blocked(self, hostname: str) -> bool:\n        \"\"\"Match configured domains on label boundaries, including subdomains.\"\"\"\n        for rule in self.blocked_domains:\n            if hostname == rule or hostname.endswith(\".\" + rule):\n                return True\n        return False\n\n\ndef ensure_same_origin(base_url: str, candidate_url: str) -> None:\n    \"\"\"Reject path or authentication data that changes scheme, host, or port.\"\"\"\n    if _origin(base_url) != _origin(candidate_url):\n        raise OutboundPolicyError(\"Tool path must not change the endpoint origin\")\n","sourceCodeStart":102,"sourceCodeEnd":138,"githubUrl":"https://github.com/iflytek/astron-agent/blob/5e758547a83371a5a4b29dadf4ac03e8dd527635/core/plugin/link/infra/tool_exector/ssrf_guard.py#L102-L138","documentation":"OutboundPolicyError raised in validate_address when the target IP falls into one of the hardcoded _NEVER_CONNECT_NETWORKS (unspecified, loopback, link-local, multicast, reserved, documentation ranges like 192.0.2.0/24, NAT64 64:ff9b::/96, 6to4 2002::/16, etc.). Unlike the blocklist error, this deny set is built into the library and cannot be configured away. It fires after the literal-whitelist exception check, so even whitelisted literals cannot reach these ranges.","triggerScenarios":"validate_url/create_socket_factory given a literal IP in a never-connect range (e.g. http://127.0.0.1, http://0.0.0.0, http://[::1], http://203.0.113.5), or a hostname whose DNS resolution in socket_factory returns such an address; also via 64:ff9b::/96 or 2002::/16 NAT/6to4 embedded addresses.","commonSituations":"Developers pointing tools at localhost or a docker-internal IP without adding it to PRIVATE_ENDPOINT_ALLOW_LIST (note: private-endpoint allow-listing does NOT bypass never-connect ranges for loopback — loopback is rejected unconditionally); using documentation/example IPs (203.0.113.x, 2001:db8::) copied from docs; DNS returning link-local (169.254.x) addresses during infrastructure issues.","solutions":["Replace loopback/unspecified/documentation literal IPs with the real routable address of the target service.","For genuinely intended private endpoints (RFC1918, site-local), register the exact endpoint in PRIVATE_ENDPOINT_ALLOW_LIST — but note loopback, link-local, multicast and reserved ranges are still rejected unconditionally.","If DNS resolves to a never-connect address (e.g. 169.254.x), fix the DNS/service-discovery record.","If you truly need one of these ranges (e.g. tests), run against a non-never-connect private address instead; the check is unconditional and not configurable."],"exampleFix":"// before\nurl = \"http://127.0.0.1:8080/api\"\n// after\nurl = \"http://192.168.10.5:8080/api\"  # plus PRIVATE_ENDPOINT_ALLOW_LIST=http://192.168.10.5:8080/api","handlingStrategy":"validation","validationCode":"import ipaddress\n\n_NEVER = [\"0.0.0.0/8\", \"127.0.0.0/8\", \"169.254.0.0/16\", \"224.0.0.0/4\", \"240.0.0.0/4\",\n          \"192.0.2.0/24\", \"198.51.100.0/24\", \"203.0.113.0/24\", \"2001:db8::/32\", \"2002::/16\"]\n\ndef is_never_connect(host: str) -> bool:\n    try:\n        addr = ipaddress.ip_address(host)\n    except ValueError:\n        return False\n    return any(addr in ipaddress.ip_network(n) for n in _NEVER)\n\n# before the call: if is_never_connect(\"127.0.0.1\"): raise ValueError(\"use a real endpoint\")","typeGuard":"def is_safe_literal_host(host: str) -> bool:\n    try:\n        addr = ipaddress.ip_address(host)\n    except ValueError:\n        return True  # hostname, resolved later\n    return not (addr.is_loopback or addr.is_unspecified or addr.is_link_local or addr.is_multicast or addr.is_reserved)","tryCatchPattern":"try:\n    policy.validate_url(url)\nexcept OutboundPolicyError as exc:\n    if \"unsafe\" in str(exc):\n        raise ValueError(f\"{url} targets a reserved/loopback network; use a routable endpoint\") from exc\n    raise","preventionTips":["Never hardcode localhost, 0.0.0.0, or RFC 5737 documentation IPs in tool configs.","Remember the never-connect check is unconditional: even IP_WHITE_LIST or PRIVATE_ENDPOINT_ALLOW_LIST cannot bypass it.","Watch for NAT64 (64:ff9b::/96) and 6to4 (2002::/16) synthesized addresses when dual-stack DNS is in play.","Alert on this error in production: it often indicates a DNS or misconfiguration problem rather than an attack."],"tags":["ssrf","security","network"],"backgroundTag":"invalid-argument-value","analyzedSha":"5e758547a83371a5a4b29dadf4ac03e8dd527635","analyzedAt":"2026-09-12T08:03:51.356Z","contentChangedAt":"2026-09-12T08:03:51.356Z","schemaVersion":2},"datasetVersion":"2026-09-15T23:17:13.987Z"}