{"record":{"id":"835fd20659f31832","repo":"iflytek/astron-agent","slug":"outbound-address-is-not-globally-routable","errorCode":null,"errorMessage":"Outbound address is not globally routable","messagePattern":"Outbound address is not globally routable","errorType":"exception","errorClass":"OutboundPolicyError","httpStatus":null,"severity":"error","filePath":"core/plugin/link/infra/tool_exector/ssrf_guard.py","lineNumber":124,"sourceCode":"        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\n\ndef create_socket_factory(\n    policy: OutboundPolicy,\n    target_url: str,","sourceCodeStart":106,"sourceCodeEnd":142,"githubUrl":"https://github.com/iflytek/astron-agent/blob/5e758547a83371a5a4b29dadf4ac03e8dd527635/core/plugin/link/infra/tool_exector/ssrf_guard.py#L106-L142","documentation":"OutboundPolicyError raised in validate_address as the final check: when neither the literal exception nor the private-endpoint exception applies, the resolved/canonical IP must be globally routable (ipaddress.is_global). Anything private (RFC1918, ULA, loopback, etc.) is rejected to prevent SSRF into internal infrastructure. IPv4-mapped IPv6 addresses are canonicalized to their IPv4 form first.","triggerScenarios":"validate_url or socket_factory resolving a hostname to a private/non-global address (10.x, 172.16-31.x, 192.168.x, fc00::/7, etc.) while allow_private_endpoint is False — i.e. the endpoint is not listed in PRIVATE_ENDPOINT_ALLOW_LIST, or the path/query contains ';' or a query string so is_private_endpoint_allowed returns False.","commonSituations":"Calling an internal microservice by hostname that resolves to an RFC1918 address without allow-listing it; allow-list entry mismatch caused by a query string or a semicolon/matrix parameter in the tool path; docker-compose service names resolving to container-internal IPs; IPv6 ULA (fd00::/8) addresses from modern DNS setups.","solutions":["Add the exact endpoint to PRIVATE_ENDPOINT_ALLOW_LIST as scheme://host:port/path with no query string and no ';' in the path (format is strictly validated).","Remove any query string or matrix-parameter (';') segments from the tool URL so the allow-list comparison in is_private_endpoint_allowed matches.","If the service should be public, expose it on a globally routable address instead of an internal one.","Verify the allow-list entry's scheme, normalized host, and default port (443/https, 80/http) exactly match the URL — the comparison is an exact tuple match."],"exampleFix":"// before\nurl = \"http://internal-svc:8080/api?x=1\"  # query defeats allow-list\n// after\nurl = \"http://internal-svc:8080/api\"  # PRIVATE_ENDPOINT_ALLOW_LIST=http://internal-svc:8080/api","handlingStrategy":"validation","validationCode":"from urllib.parse import urlsplit\n\ndef endpoint_matches_allow_list(url: str, allow_list: list[str]) -> bool:\n    p = urlsplit(url)\n    if p.query or \";\" in p.path:\n        return False  # query/matrix params disqualify allow-list matching\n    port = p.port or (443 if p.scheme == \"https\" else 80)\n    entry = f\"{p.scheme}://{p.hostname}:{port}{p.path or '/'}\"\n    normalized = [a.rstrip('/') for a in allow_list]\n    return entry.rstrip('/') in normalized\n\n# verify before deploying: endpoint_matches_allow_list(\"http://internal-svc:8080/api\", allow_list)","typeGuard":"def is_plain_path(url: str) -> bool:\n    p = urlsplit(url)\n    return not p.query and \";\" not in p.path","tryCatchPattern":"try:\n    policy.validate_url(url)\nexcept OutboundPolicyError as exc:\n    if \"not globally routable\" in str(exc):\n        logger.error(\"endpoint %s resolves to a private address and is not in PRIVATE_ENDPOINT_ALLOW_LIST\", url)\n        raise\n    raise","preventionTips":["Every internal endpoint a tool may call must be registered verbatim in PRIVATE_ENDPOINT_ALLOW_LIST (no query, no ';', exact path).","Keep tool URLs path-only when talking to allow-listed private endpoints; move parameters into the body.","Remember allow-list matching normalizes the default port (443/https, 80/http) and lowercases/IDNA-normalizes the host.","Test with the same DNS the service uses in production — container-internal names resolve to private IPs that require allow-listing."],"tags":["ssrf","security","network","configuration"],"backgroundTag":"permission-denied","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"}