{"record":{"id":"03f6ab7f0ff4b409","repo":"iflytek/astron-agent","slug":"tool-path-must-not-change-the-endpoint-origin","errorCode":null,"errorMessage":"Tool path must not change the endpoint origin","messagePattern":"Tool path must not change the endpoint origin","errorType":"exception","errorClass":"OutboundPolicyError","httpStatus":null,"severity":"error","filePath":"core/plugin/link/infra/tool_exector/ssrf_guard.py","lineNumber":137,"sourceCode":"        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,\n) -> Callable[[aiohttp.AddrInfoType], socket.socket]:\n    \"\"\"Create an aiohttp socket factory that checks the actual target sockaddr.\"\"\"\n    parsed = policy.validate_url(target_url)\n    hostname = _normalize_hostname(parsed.hostname or \"\")\n    literal_host = _parse_ip(hostname) is not None\n    allow_private_endpoint = policy.is_private_endpoint_allowed(parsed)\n\n    def socket_factory(addr_info: aiohttp.AddrInfoType) -> socket.socket:\n        family, type_, proto, _, sockaddr = addr_info\n        try:\n            address = ipaddress.ip_address(sockaddr[0])\n        except ValueError as exc:\n            raise OutboundPolicyError(\"Resolved outbound address is invalid\") from exc","sourceCodeStart":119,"sourceCodeEnd":155,"githubUrl":"https://github.com/iflytek/astron-agent/blob/5e758547a83371a5a4b29dadf4ac03e8dd527635/core/plugin/link/infra/tool_exector/ssrf_guard.py#L119-L155","documentation":"OutboundPolicyError raised by ensure_same_origin when the candidate URL (typically a URL built from a tool result or redirect path) has a different origin — scheme, normalized host, or effective port — than the base URL of the tool call. This guards against tool responses rewriting the request target to a different host/scheme (e.g. open-redirect style escalation). Relative paths are allowed; cross-origin changes are not.","triggerScenarios":"Calling ensure_same_origin(base_url, candidate_url) where candidate changes scheme (https→http or vice versa), host (including trivially different spellings), or port; base or candidate URL lacking http/https scheme or hostname also raises earlier 'origin is invalid' from _origin. Called by _build_url and same-origin tests.","commonSituations":"A tool/API response returns an absolute URL on another host or plain-http URL that the client then tries to follow; building a follow-up request from a Location header that points off-origin; a base configured with an implicit port while the candidate spells it out inconsistently with scheme defaults (normalized, so mismatch means real change); credentials in the candidate URL (rejected separately as user info).","solutions":["Inspect the candidate URL: ensure its scheme, host (lowercased/normalized) and effective port exactly equal the base URL's origin.","Strip absolute URLs returned by the tool down to their path and re-attach them to the base origin before building the follow-up request.","If the tool legitimately needs a different endpoint, configure a separate tool/endpoint for it instead of rewriting the base URL.","Fix scheme mismatches (http vs https) and port mismatches (e.g. :443 with http, or :80 with https) in the tool's configured base URL."],"exampleFix":"// before\nnext_url = response_json[\"callback\"]  # \"http://evil.example/api\"\nensure_same_origin(base_url, next_url)\n// after\nfrom urllib.parse import urlsplit\npath = urlsplit(response_json[\"callback\"]).path\nnext_url = base_url.rstrip(\"/\") + path","handlingStrategy":"validation","validationCode":"from urllib.parse import urlsplit\n\ndef same_origin(base_url: str, candidate_url: str) -> bool:\n    b, c = urlsplit(base_url), urlsplit(candidate_url)\n    def origin(p):\n        return (p.scheme.lower(), (p.hostname or \"\").lower().rstrip(\".\"),\n                p.port if p.port is not None else (443 if p.scheme.lower() == \"https\" else 80))\n    return origin(b) == origin(c)\n\n# call ensure_same_origin only after confirming same_origin(base_url, candidate)","typeGuard":"def is_relative_path(candidate: str) -> bool:\n    return not urlsplit(candidate).scheme and not urlsplit(candidate).netloc","tryCatchPattern":"try:\n    ensure_same_origin(base_url, candidate_url)\nexcept OutboundPolicyError as exc:\n    logger.warning(\"tool attempted cross-origin redirect %s -> %s\", base_url, candidate_url)\n    return None  # or fall back to base_url + path of candidate","preventionTips":["Treat any absolute URL returned by a tool response as untrusted; extract only its path and rejoin to the base origin.","Never follow Location headers across origins in follow-up tool requests.","Keep scheme consistent (https everywhere) to avoid trivial http/https origin mismatches.","Cover _build_url with unit tests using hostile candidates (absolute other-host URLs, scheme-switching, credential-bearing URLs)."],"tags":["ssrf","security","url","open-redirect"],"backgroundTag":"invalid-url","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"}