http/websocket/gql: a redirect never enters loopback, and loopback is what the resolver says it is

Merged
H3
2mo ago
+356-44

Security fix, against main for a 1.1.x patch release of utcp-http, utcp-websocket and utcp-gql (cubic raised the first gap on utcp-specification #66 from the HTTP protocol page's own description of the behaviour, and the second here).

Gap 1 — a remote origin could redirect the client into loopback

safe_request_with_redirects re-validates every hop with ensure_secure_url, which accepts plain HTTP to loopback for local development. So https://attacker.example/manual → 302 → http://127.0.0.1:9200/… was followed: an SSRF into the agent's own services, issued with the manual's headers. And because reject_remote_loopback_tool_urls judges by the final URL, whatever loopback served then counted as locally discovered and could declare loopback tool URLs — a local UTCP-speaking service becomes callable through the client at a remote attacker's direction. The existing "redirect to loopback is allowed" test only ever covered loopback→loopback.

Rule: a redirect never enters loopback from a non-loopback URL. The loopback allowance is for requests the caller addressed to loopback, never for ones a remote server steers there. This also makes the final-URL rule sound by construction: a final loopback URL now means the chain started on loopback and never left it. Loopback may still redirect out (and loses the local-dev exemption when it does).

Gap 2 — loopback was classified narrower than the resolver resolves it

is_loopback_url went through Python's ipaddress, which rejects the spellings the OS resolver routes to 127.0.0.1: 127.1, 2130706433, 0177.0.0.1, 0x7f000001, and the trailing-dot forms 127.0.0.1. / localhost.. Over HTTPS each read as "HTTPS anywhere" and slipped past the redirect guard, the remote-manual rule and the OpenAPI converter's servers[0] check alike.

Rule: one classifier for every loopback decision (_is_loopback_host): trailing dot stripped; canonical names, wildcard, 127/8 and IPv4-mapped forms as before; and when ipaddress rejects a numeric literal, the resolver's own parser (inet_aton) is asked and its result classified. The plain-HTTP allowance, is_loopback_url, the redirect guard, the remote-manual rule and the converter all go through it, so they cannot disagree about what loopback is.

Mirrors

utcp_websocket and utcp_gql carry deliberate copies of this module ("keep in sync"). Both had both gaps; both now carry both fixes.

Tests

  • Remote origin cannot redirect into loopback — canonical, hostname, HTTPS-on-loopback, IPv6, and every resolver spelling — and the loopback request is never issued; a chain that left loopback cannot be sent back. Scripted session, since every test server here lives on loopback.
  • The remote-manual rule rejects loopback tool URLs in every spelling.
  • Each mirror: the spellings, the lookalikes that must stay non-loopback (localhost.evil.com, 127.0.0.1.attacker.example, 10.1), and the remote-origin redirect.

Mutation-checked: dropping the redirect rule fails exactly its tests; dropping the resolver path fails exactly the spelling tests, in all three modules. http 255/255, websocket 52/52, gql 37/37.

The HTTP protocol page in the spec (utcp-specification #66, merged) describes the redirect rule.

🤖 Generated with Claude Code