Skip to content

Commit e9bcebc

Browse files
authored
Merge pull request #109 from universal-tool-calling-protocol/fix/no-redirect-into-loopback
http/websocket/gql: a redirect never enters loopback, and loopback is what the resolver says it is
2 parents 6b4f364 + 0cde101 commit e9bcebc

7 files changed

Lines changed: 356 additions & 44 deletions

File tree

‎plugins/communication_protocols/gql/src/utcp_gql/_security.py‎

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from __future__ import annotations
1212

1313
import re
14+
import socket
1415
from contextlib import asynccontextmanager
1516
from ipaddress import IPv6Address, ip_address
1617
from typing import Any, AsyncIterator, Dict, Optional
@@ -67,22 +68,42 @@ def is_secure_url(url: str) -> bool:
6768
return False
6869

6970

71+
# An IPv4 literal in any of the spellings the resolver accepts: dotted
72+
# quads, fewer than four parts (``127.1``), a single integer
73+
# (``2130706433``), octal (``0177.0.0.1``) or hex (``0x7f000001``) parts.
74+
_RESOLVER_IPV4_LITERAL = re.compile(r"^[0-9a-fx.]+$")
75+
76+
7077
def _ip_is_loopback_like(host: str) -> bool:
71-
"""Mirror of ``utcp_http._security._ip_is_loopback_like``. See that
72-
module for the full rationale -- covers 127.0.0.0/8, ::1, 0.0.0.0,
73-
::, and IPv4-mapped IPv6 loopback addresses.
78+
"""Mirror of ``utcp_http._security._is_loopback_host``. See that module
79+
for the full rationale: True if ``host`` names the machine running the
80+
agent, in any spelling the resolver routes locally -- ``localhost`` /
81+
``127.0.0.1`` / ``::1``, a trailing-dot absolute name, ``0.0.0.0`` /
82+
``::``, any 127.0.0.0/8 address, IPv4-mapped IPv6 forms, and the
83+
shorthand/integer/octal/hex IPv4 spellings ``inet_aton`` accepts
84+
(``127.1``, ``2130706433``, ``0177.0.0.1``, ``0x7f000001``) that
85+
``ipaddress`` rejects.
7486
"""
75-
if host in {"0.0.0.0", "::"}:
87+
host = host.rstrip(".")
88+
if not host:
89+
return False
90+
if host in _LOOPBACK_HOSTNAMES or host in {"0.0.0.0", "::"}:
7691
return True
7792
try:
7893
addr = ip_address(host)
7994
except ValueError:
80-
return False
81-
if addr.is_loopback:
95+
# Not a form ``ipaddress`` understands; ask the resolver's parser.
96+
if not _RESOLVER_IPV4_LITERAL.match(host):
97+
return False
98+
try:
99+
addr = ip_address(socket.inet_ntoa(socket.inet_aton(host)))
100+
except (OSError, ValueError):
101+
return False
102+
if addr.is_loopback or addr.is_unspecified:
82103
return True
83104
if isinstance(addr, IPv6Address):
84105
mapped = addr.ipv4_mapped
85-
if mapped is not None and mapped.is_loopback:
106+
if mapped is not None and (mapped.is_loopback or mapped.is_unspecified):
86107
return True
87108
return False
88109

@@ -340,6 +361,19 @@ async def safe_request_with_redirects(
340361
ensure_secure_url(
341362
next_url, context=f"{context} (redirect target)"
342363
)
364+
# Mirror of utcp_http: a redirect never enters loopback from
365+
# a non-loopback URL. The loopback allowance is for requests
366+
# the caller addressed to loopback, not for ones a remote
367+
# server steers there.
368+
if is_loopback_url(next_url) and not is_loopback_url(current_url):
369+
raise ValueError(
370+
f"Security error during {context} (redirect target): "
371+
f"{current_url!r} redirected to the loopback address "
372+
f"{next_url!r}. A redirect is never followed into "
373+
"loopback from a non-loopback origin: the loopback "
374+
"allowance is for requests addressed to loopback by "
375+
"the caller, not for ones a remote server steers there."
376+
)
343377
except Exception:
344378
response.release()
345379
raise

‎plugins/communication_protocols/gql/tests/test_gql_security.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,63 @@ async def test_plain_http_non_loopback_token_url_rejected(self) -> None:
109109
)
110110
with pytest.raises(ValueError, match="OAuth2 token URL"):
111111
await proto._handle_oauth2(auth)
112+
113+
114+
# ---------------------------------------------------------------------------
115+
# Mirror of utcp_http: loopback in every spelling the resolver accepts, and a
116+
# redirect never enters loopback from a non-loopback origin.
117+
# ---------------------------------------------------------------------------
118+
119+
import pytest as _pytest
120+
from utcp_gql._security import is_loopback_url as _is_loopback_url, safe_request_with_redirects as _safe_request_with_redirects
121+
122+
123+
@_pytest.mark.parametrize(
124+
"url",
125+
[
126+
"https://127.1/x", # shorthand: inet_aton fills the middle octets
127+
"https://2130706433/x", # 127.0.0.1 as a single integer
128+
"https://0177.0.0.1/x", # octal
129+
"https://0x7f000001/x", # hex
130+
"https://127.0.0.1./x", # absolute-name form (trailing dot)
131+
"https://localhost./x",
132+
"https://0.0.0.0/x", # wildcard routes to the local host
133+
"https://[::ffff:127.0.0.1]/x",
134+
],
135+
)
136+
def test_loopback_is_recognised_in_every_resolver_spelling(url):
137+
assert _is_loopback_url(url)
138+
139+
140+
@_pytest.mark.parametrize("url", ["https://localhost.evil.com/x", "https://127.0.0.1.attacker.example/x", "https://10.1/x"])
141+
def test_lookalikes_and_other_networks_are_not_loopback(url):
142+
assert not _is_loopback_url(url)
143+
144+
145+
class _FakeResponse:
146+
def __init__(self, status, headers, url):
147+
self.status, self.headers, self.url = status, headers, url
148+
149+
def release(self):
150+
pass
151+
152+
153+
class _ScriptedSession:
154+
def __init__(self, script):
155+
self.script, self.requested = script, []
156+
157+
async def request(self, method, url, **kwargs):
158+
self.requested.append(url)
159+
return self.script[url]
160+
161+
162+
@_pytest.mark.asyncio
163+
@_pytest.mark.parametrize("loopback_target", ["http://127.0.0.1:9200/x", "https://127.1/x", "https://localhost./x"])
164+
async def test_remote_origin_cannot_redirect_into_loopback(loopback_target):
165+
remote = "https://attacker.example/manual"
166+
session = _ScriptedSession({remote: _FakeResponse(302, {"Location": loopback_target}, remote)})
167+
with _pytest.raises(ValueError, match="never followed into loopback"):
168+
async with _safe_request_with_redirects(session, "GET", remote, context="manual discovery"):
169+
pass
170+
# The request to the agent's own service was never issued.
171+
assert session.requested == [remote]

‎plugins/communication_protocols/http/src/utcp_http/_security.py‎

Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from __future__ import annotations
1111

1212
import re
13+
import socket
1314
from contextlib import asynccontextmanager
1415
from ipaddress import IPv6Address, ip_address
1516
from typing import Any, AsyncIterator, Dict, Optional
@@ -55,49 +56,64 @@ def is_secure_url(url: str) -> bool:
5556
return True
5657

5758
# http:// is only allowed for loopback.
58-
if host in _LOOPBACK_HOSTNAMES:
59-
return True
59+
return _is_loopback_host(host)
6060

61-
# Catch any other literal loopback IP that urlparse normalised
62-
# (e.g. ``http://127.000.000.001``).
63-
try:
64-
return ip_address(host).is_loopback
65-
except ValueError:
66-
return False
6761

62+
# An IPv4 literal in any of the spellings the resolver accepts: dotted
63+
# quads, fewer than four parts (``127.1``), a single integer
64+
# (``2130706433``), octal (``0177.0.0.1``) or hex (``0x7f000001``) parts.
65+
_RESOLVER_IPV4_LITERAL = re.compile(r"^[0-9a-fx.]+$")
6866

69-
def _ip_is_loopback_like(host: str) -> bool:
70-
"""Return True if ``host`` is an IP literal that the local kernel will
71-
route to the host running the agent.
67+
68+
def _is_loopback_host(host: str) -> bool:
69+
"""Return True if ``host`` names the machine running the agent.
70+
71+
``host`` is a hostname as ``urlparse`` returns it (lowercase, no
72+
brackets). One classifier for every loopback decision -- the plain-HTTP
73+
allowance, the remote-manual rule, the redirect guard, the OpenAPI
74+
converter -- so they cannot disagree about what loopback is.
7275
7376
Wider than Python's stdlib ``ip_address(...).is_loopback`` because we
74-
must also defend against:
75-
76-
* ``0.0.0.0`` -- on Linux a TCP connect to 0.0.0.0 lands on 127.0.0.1.
77-
* ``::`` -- the IPv6 equivalent of ``0.0.0.0``.
78-
* IPv4-mapped IPv6 forms of any 127.0.0.0/8 address (e.g.
79-
``::ffff:127.0.0.1``, ``::ffff:127.0.0.2``) -- ``ipaddress`` does
80-
not treat these as loopback per RFC 4291, but the dual-stack
81-
socket layer routes them to the v4 loopback.
82-
83-
Used by the OpenAPI converter to detect attacker-controlled
84-
``servers[0].url`` values that point at the agent's own loopback
85-
interface (the GHSA-39j6-4867-gg4w SSRF pattern). Hostname-based,
86-
never prefix-based.
77+
must classify every spelling the *resolver* will route locally:
78+
79+
* ``localhost`` and the canonical ``127.0.0.1`` / ``::1``.
80+
* A trailing dot (``localhost.``, ``127.0.0.1.``): the absolute-name
81+
form, which resolves the same.
82+
* ``0.0.0.0`` -- on Linux a TCP connect to 0.0.0.0 lands on 127.0.0.1;
83+
``::`` is the IPv6 equivalent.
84+
* Any 127.0.0.0/8 address.
85+
* IPv4-mapped IPv6 forms of those (``::ffff:127.0.0.1``) -- not
86+
loopback per RFC 4291, but the dual-stack socket layer routes them
87+
to the v4 loopback.
88+
* The shorthand, integer, octal and hex IPv4 spellings ``inet_aton``
89+
accepts (``127.1``, ``2130706433``, ``0177.0.0.1``, ``0x7f000001``)
90+
-- ``ipaddress`` rejects them, the OS resolver does not.
91+
92+
Hostname-based, never prefix-based: ``localhost.evil.com`` and
93+
``127.0.0.1.attacker.example`` are not loopback.
8794
"""
88-
if host in {"0.0.0.0", "::"}:
95+
host = host.rstrip(".")
96+
if not host:
97+
return False
98+
if host in _LOOPBACK_HOSTNAMES or host in {"0.0.0.0", "::"}:
8999
return True
90100
try:
91101
addr = ip_address(host)
92102
except ValueError:
93-
return False
94-
if addr.is_loopback:
103+
# Not a form ``ipaddress`` understands; ask the resolver's parser.
104+
if not _RESOLVER_IPV4_LITERAL.match(host):
105+
return False
106+
try:
107+
addr = ip_address(socket.inet_ntoa(socket.inet_aton(host)))
108+
except (OSError, ValueError):
109+
return False
110+
if addr.is_loopback or addr.is_unspecified:
95111
return True
96112
# IPv4-mapped IPv6 loopback (``::ffff:127.0.0.1`` etc.) -- the
97113
# ``ipv4_mapped`` accessor surfaces the embedded v4 address.
98114
if isinstance(addr, IPv6Address):
99115
mapped = addr.ipv4_mapped
100-
if mapped is not None and mapped.is_loopback:
116+
if mapped is not None and (mapped.is_loopback or mapped.is_unspecified):
101117
return True
102118
return False
103119

@@ -124,10 +140,7 @@ def is_loopback_url(url: str) -> bool:
124140
if not host:
125141
return False
126142

127-
if host in _LOOPBACK_HOSTNAMES:
128-
return True
129-
130-
return _ip_is_loopback_like(host)
143+
return _is_loopback_host(host)
131144

132145

133146
def ensure_secure_url(url: str, *, context: Optional[str] = None) -> None:
@@ -380,6 +393,14 @@ async def safe_request_with_redirects(
380393
target against the current URL and runs ``ensure_secure_url``
381394
on it before issuing the next hop. Rejection raises and the
382395
redirect chain is aborted with the connection released.
396+
* Never follows a redirect INTO loopback from a non-loopback URL.
397+
The loopback allowance in ``ensure_secure_url`` exists for
398+
requests the caller addressed to loopback (local development);
399+
a remote server must not be able to steer a request at the
400+
agent's own services, nor make a loopback-served manual look
401+
locally discovered (``reject_remote_loopback_tool_urls`` judges
402+
by the final URL, which this rule keeps honest: a final loopback
403+
URL means the chain started on loopback and never left it).
383404
* Caps the chain at ``max_redirects`` hops. Exceeding that raises
384405
``RuntimeError``.
385406
* Mirrors RFC 7231 method semantics: 303 forces ``GET`` and drops
@@ -450,6 +471,15 @@ async def safe_request_with_redirects(
450471
ensure_secure_url(
451472
next_url, context=f"{context} (redirect target)"
452473
)
474+
if is_loopback_url(next_url) and not is_loopback_url(current_url):
475+
raise ValueError(
476+
f"Security error during {context} (redirect target): "
477+
f"{current_url!r} redirected to the loopback address "
478+
f"{next_url!r}. A redirect is never followed into "
479+
"loopback from a non-loopback origin: the loopback "
480+
"allowance is for requests addressed to loopback by "
481+
"the caller, not for ones a remote server steers there."
482+
)
453483
except Exception:
454484
response.release()
455485
raise

‎plugins/communication_protocols/http/tests/test_loopback_manual_security.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ def _manual(url: str) -> UtcpManual:
3434
"http://127.0.0.2:9200/secret", # 127.0.0.0/8, slips a naive "127.0.0.1" check
3535
"http://0.0.0.0:9200/secret", # wildcard, routes to the local host
3636
"http://[::ffff:127.0.0.1]/secret", # IPv4-mapped IPv6 loopback
37+
# Spellings the resolver accepts but Python's ipaddress rejects
38+
"https://127.1/secret", # shorthand: inet_aton fills the middle octets
39+
"https://2130706433/secret", # 127.0.0.1 as a single integer
40+
"https://0177.0.0.1/secret", # octal first octet
41+
"https://0x7f000001/secret", # hex
42+
"https://127.0.0.1./secret", # absolute-name form (trailing dot)
43+
"https://localhost./secret", # same, for the hostname
3744
],
3845
)
3946
def test_remote_manual_with_loopback_tool_url_is_rejected(tool_url):

‎plugins/communication_protocols/http/tests/test_redirect_security.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,3 +958,88 @@ def test_legitimate_https_token_url_accepted(self) -> None:
958958
)
959959
manual = converter.convert()
960960
assert len(manual.tools) == 1
961+
962+
963+
# ---------------------------------------------------------------------------
964+
# A redirect never enters loopback from a non-loopback origin.
965+
# ---------------------------------------------------------------------------
966+
967+
968+
class _FakeResponse:
969+
def __init__(self, status: int, headers: dict, url: str):
970+
self.status = status
971+
self.headers = headers
972+
self.url = url
973+
self.released = False
974+
975+
def release(self) -> None:
976+
self.released = True
977+
978+
979+
class _ScriptedSession:
980+
"""Answers each URL from a script and records every request it was asked
981+
to issue -- the test servers all live on loopback, so a genuinely remote
982+
origin has to be scripted."""
983+
984+
def __init__(self, script: dict):
985+
self.script = script
986+
self.requested: list = []
987+
988+
async def request(self, method: str, url: str, **kwargs):
989+
self.requested.append(url)
990+
return self.script[url]
991+
992+
993+
class TestRedirectNeverEntersLoopback:
994+
995+
@pytest.mark.asyncio
996+
@pytest.mark.parametrize(
997+
"loopback_target",
998+
[
999+
"http://127.0.0.1:9200/_cat/indices", # canonical loopback
1000+
"http://localhost:9200/_cat/indices", # loopback hostname
1001+
"https://127.0.0.1:8443/admin", # HTTPS does not make loopback remote
1002+
"http://[::1]:9200/_cat/indices", # IPv6 loopback
1003+
# Spellings the resolver routes to loopback but Python's ipaddress
1004+
# rejects -- over HTTPS, so only the loopback classification stands
1005+
# between them and being "HTTPS anywhere"
1006+
"https://127.1/admin", # shorthand
1007+
"https://2130706433/admin", # single integer
1008+
"https://0177.0.0.1/admin", # octal
1009+
"https://0x7f000001/admin", # hex
1010+
"https://127.0.0.1./admin", # absolute-name form
1011+
"https://localhost./admin",
1012+
],
1013+
)
1014+
async def test_remote_origin_cannot_redirect_into_loopback(self, loopback_target) -> None:
1015+
# The remote hop is secure (HTTPS) and the loopback hop would pass
1016+
# ensure_secure_url on its own -- only the direction is illegal.
1017+
remote = "https://attacker.example/manual"
1018+
session = _ScriptedSession({
1019+
remote: _FakeResponse(302, {"Location": loopback_target}, remote),
1020+
})
1021+
1022+
with pytest.raises(ValueError, match="never followed into loopback"):
1023+
async with safe_request_with_redirects(session, "GET", remote, context="manual discovery"):
1024+
pass
1025+
1026+
# The request to the agent's own service was never issued.
1027+
assert session.requested == [remote]
1028+
1029+
@pytest.mark.asyncio
1030+
async def test_loopback_may_leave_but_a_chain_that_left_cannot_come_back(self) -> None:
1031+
# loopback -> remote is fine (the remote hop is validated like any
1032+
# other and the caller loses the local-dev exemption); the remote's
1033+
# attempt to send us back to loopback is refused.
1034+
local = "http://127.0.0.1:8765/manual"
1035+
remote = "https://mirror.example/manual"
1036+
session = _ScriptedSession({
1037+
local: _FakeResponse(302, {"Location": remote}, local),
1038+
remote: _FakeResponse(302, {"Location": "http://127.0.0.1:9200/secret"}, remote),
1039+
})
1040+
1041+
with pytest.raises(ValueError, match="never followed into loopback"):
1042+
async with safe_request_with_redirects(session, "GET", local, context="manual discovery"):
1043+
pass
1044+
1045+
assert session.requested == [local, remote]

0 commit comments

Comments
 (0)