Skip to content

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

Merged
h3xxit merged 2 commits into
mainfrom
fix/no-redirect-into-loopback
Sep 16, 2026
Merged

h3xxit merged 2 commits into
mainfrom
fix/no-redirect-into-loopback

Conversation

@h3xxit

@h3xxit h3xxit commented Sep 16, 2026

Copy link
Copy Markdown
Member

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

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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread plugins/communication_protocols/http/src/utcp_http/_security.py
@h3xxit
h3xxit changed the base branch from main to dev September 16, 2026 14:15
@h3xxit
h3xxit changed the base branch from dev to main September 16, 2026 14:17
…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 h3xxit changed the title http: a redirect never enters loopback from a non-loopback origin http/websocket/gql: a redirect never enters loopback, and loopback is what the resolver says it is Sep 16, 2026
@h3xxit
h3xxit merged commit e9bcebc into main Sep 16, 2026
10 checks passed
@h3xxit
h3xxit deleted the fix/no-redirect-into-loopback branch September 16, 2026 14:24
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant