http/websocket/gql: a redirect never enters loopback, and loopback is what the resolver says it is - #109
Merged
Conversation
safe_request_with_redirects re-validated every hop with ensure_secure_url, which accepts plain HTTP to loopback for local development. So a remote discovery or tool URL could 302 the client into http://127.0.0.1:... — 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. The existing 'redirect to loopback is allowed' test only ever covered loopback-to-loopback. The helper now refuses any hop whose target is loopback when the URL being redirected is not: the loopback allowance is for requests the caller addressed to loopback, never for ones a remote server steers there. That 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 leave, and loses the local-dev exemption when it does). Documented on the helper. Tests: a remote origin cannot redirect into loopback (canonical, hostname, HTTPS and IPv6 loopback forms) 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. Mutation- checked: dropping the rule fails exactly those five. http suite 243/243. Raised by cubic on utcp-specification #66, from the sentence in the HTTP protocol page that described this behaviour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
All reported issues were addressed across 2 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…ves it cubic on #109: the redirect guard asked is_loopback_url, which classifies through Python's ipaddress -- and ipaddress rejects the spellings the OS resolver happily routes to 127.0.0.1: the shorthand 127.1, the integer 2130706433, octal 0177.0.0.1, hex 0x7f000001, and the absolute-name forms 127.0.0.1. / localhost. with a trailing dot. Over HTTPS every one of them passed ensure_secure_url as "HTTPS anywhere" and slipped past the new guard, and past the remote-manual rule and the OpenAPI converter's servers[0] check for the same reason. One classifier now answers every loopback question in utcp_http -- _is_loopback_host: 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. It strips a trailing dot, accepts the canonical names, wildcard and any 127/8 or IPv4-mapped form as before, and when ipaddress rejects a numeric literal it asks the resolver's own parser (inet_aton) and classifies the result. utcp_websocket and utcp_gql carry mirrors of this module by design ("keep in sync"); both were missing the redirect-into-loopback rule from the first commit as well as the spellings. Both now carry both. Tests: the redirect guard and the remote-manual rule are parametrized over the new spellings (http); each mirror gets the spellings, the lookalikes that must stay non-loopback, and the remote-origin redirect. Mutation-checked in all three modules: dropping the resolver path fails exactly the spelling tests. http 255/255, websocket 52/52, gql 37/37. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
h3xxit
added a commit
that referenced
this pull request
Sep 16, 2026
utcp-http 1.1.13 -> 1.1.14, utcp-websocket 1.1.5 -> 1.1.6, utcp-gql 1.1.5 -> 1.1.6: a redirect never enters loopback from a non-loopback origin, and loopback is classified in every spelling the resolver accepts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security fix, against
mainfor a 1.1.x patch release ofutcp-http,utcp-websocketandutcp-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_redirectsre-validates every hop withensure_secure_url, which accepts plain HTTP to loopback for local development. Sohttps://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 becausereject_remote_loopback_tool_urlsjudges 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_urlwent through Python'sipaddress, which rejects the spellings the OS resolver routes to127.0.0.1:127.1,2130706433,0177.0.0.1,0x7f000001, and the trailing-dot forms127.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'sservers[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 whenipaddressrejects 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_websocketandutcp_gqlcarry deliberate copies of this module ("keep in sync"). Both had both gaps; both now carry both fixes.Tests
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