{"record":{"id":"5fe6d3ae65c9a194","repo":"iflytek/astron-agent","slug":"outbound-url-is-malformed","errorCode":null,"errorMessage":"Outbound URL is malformed","messagePattern":"Outbound URL is malformed","errorType":"exception","errorClass":"OutboundPolicyError","httpStatus":null,"severity":"error","filePath":"core/plugin/link/infra/tool_exector/ssrf_guard.py","lineNumber":173,"sourceCode":"            raise OutboundPolicyError(\"Resolved outbound address is invalid\") from exc\n        policy.validate_address(\n            address,\n            allow_private_endpoint=allow_private_endpoint,\n            allow_literal_exception=literal_host,\n        )\n        return socket.socket(family=family, type=type_, proto=proto)\n\n    return socket_factory\n\n\ndef _origin(url: str) -> Tuple[str, str, int]:\n    try:\n        parsed = urlsplit(url)\n        scheme = parsed.scheme.lower()\n        hostname = _normalize_hostname(parsed.hostname or \"\")\n        port = parsed.port\n    except (TypeError, ValueError) as exc:\n        raise OutboundPolicyError(\"Outbound URL is malformed\") from exc\n    if scheme not in _ALLOWED_SCHEMES or not hostname:\n        raise OutboundPolicyError(\"Outbound URL origin is invalid\")\n    if parsed.username is not None or parsed.password is not None:\n        raise OutboundPolicyError(\"Outbound URL must not include user information\")\n    normalized_port = port if port is not None else (443 if scheme == \"https\" else 80)\n    return scheme, hostname, normalized_port\n\n\ndef _parse_http_url(url: str) -> SplitResult:\n    if not isinstance(url, str):\n        raise OutboundPolicyError(\"Outbound URL is malformed\")\n    _validate_url_characters(url)\n    try:\n        parsed = urlsplit(url)\n        port = parsed.port\n    except (TypeError, ValueError) as exc:\n        raise OutboundPolicyError(\"Outbound URL is malformed\") from exc\n    _validate_parsed_http_url(parsed, port)","sourceCodeStart":155,"sourceCodeEnd":191,"githubUrl":"https://github.com/iflytek/astron-agent/blob/5e758547a83371a5a4b29dadf4ac03e8dd527635/core/plugin/link/infra/tool_exector/ssrf_guard.py#L155-L191","documentation":"`_origin` normalizes a URL into a (scheme, hostname, port) tuple so the SSRF guard can compare origins. It wraps `urlsplit`/`parsed.port` in try/except: if Python's URL parser raises TypeError or ValueError (e.g. an invalid port like `:abc` or `:99999`, or a non-string passed in), the guard converts it into `OutboundPolicyError('Outbound URL is malformed')`. This is an intentional, strict-fail security check on outbound tool URLs.","triggerScenarios":"Calling `ensure_same_origin(base, candidate)` or `_endpoint(parsed)` with a URL whose netloc has an unparsable port (e.g. `https://host:abc/`, `http://host:99999/`, empty port `http://host:/x` causing ValueError from `parsed.port`), or passing a non-string (None/int) as the URL. Note `_endpoint` calls `_origin(parsed.geturl())`, so any SplitResult whose re-encoded form carries a bad port triggers this.","commonSituations":"Tool endpoint config assembled by string concatenation where a placeholder port (`{port}`) was never substituted; user-supplied URL passed through a tool schema without validation; IPv6 literal URLs with bracket/port mistakes; environment-provided PRIVATE_ENDPOINT_ALLOW_LIST entries with malformed ports (re-raised as a different message, but the same root cause).","solutions":["Print/inspect the exact URL string being validated; check the `:port` segment is numeric and within 1-65535 (omit the port entirely for default 80/443).","Ensure the value passed to `ensure_same_origin`/`_endpoint` is a `str`, not None or another type; coerce or reject earlier in your tool config loading.","URL-encode any credentials or special characters out of the authority component; if credentials are needed, the separate 'must not include user information' check will fire instead.","Validate the URL with `urllib.parse.urlsplit` + accessing `.port` yourself before calling the guard, so you get a plain ValueError with your own message."],"exampleFix":"// before\nbase = f\"https://{host}:{port}/v1\"\nensure_same_origin(base, url)  # port may be None/'{port}' -> malformed\n// after\nport = int(port) if str(port).isdigit() else None\nbase = f\"https://{host}\" + (f\":{port}\" if port else \"\") + \"/v1\"\nensure_same_origin(base, url)","handlingStrategy":"validation","validationCode":"from urllib.parse import urlsplit\n\ndef is_parsable_origin(url) -> bool:\n    if not isinstance(url, str):\n        return False\n    try:\n        p = urlsplit(url)\n        _ = p.port  # raises ValueError on bad ports\n    except (TypeError, ValueError):\n        return False\n    return p.scheme.lower() in (\"http\", \"https\") and bool(p.hostname)","typeGuard":"def is_valid_url_string(value) -> bool:\n    return isinstance(value, str) and value.startswith((\"http://\", \"https://\"))","tryCatchPattern":"try:\n    ensure_same_origin(base_url, candidate_url)\nexcept OutboundPolicyError as exc:\n    logger.warning(\"origin check failed for %r: %s\", candidate_url, exc)\n    raise HTTPBadRequest(\"Invalid tool URL\") from exc","preventionTips":["Always build URLs with a URL-building library (yarl.URL / f-strings with validated parts), never raw concatenation of unvalidated config.","Interpolate ports as int and str() them; never leave template placeholders in final URLs.","Log the offending URL (minus credentials) whenever this error is caught to speed up diagnosis.","Unit-test tool URL configuration parsing with malformed-port cases."],"tags":["security","ssrf","url-parsing","python"],"backgroundTag":"invalid-url-format","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"}