Skip to content

Commit e356ea7

Browse files
h3xxitclaude
andcommitted
fix(openapi): block remote specs from declaring loopback servers (#83)
Defense in depth on top of the runtime URL revalidation that landed in 5b16e43 (GHSA-39j6-4867-gg4w). The runtime check rejects the request once it's already on its way to the loopback interface, but the malicious tools are still registered, still surfaced to the LLM, and still try to fire on every invocation. Better to refuse them at conversion time so they never enter the registry in the first place. Rule: when an OpenAPI spec is fetched from a non-loopback URL, its ``servers[0].url`` must not be a literal loopback address. Anyone running their own UTCP agent locally pointing at a localhost OpenAPI spec stays unaffected — the source URL is itself loopback in that case. And an operator who explicitly trusts a remote spec's loopback server can still override via the call template's ``base_url`` field (handled by the existing override-takes-precedence branch). Added ``is_loopback_url`` helper to ``_security.py`` and four new converter test cases covering: rejection of remote→loopback, allowance of loopback→loopback, allowance of explicit override, and the normal remote→remote case. 106/106 utcp-http tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent de2c3a8 commit e356ea7

3 files changed

Lines changed: 157 additions & 0 deletions

File tree

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,35 @@ def is_secure_url(url: str) -> bool:
6464
return False
6565

6666

67+
def is_loopback_url(url: str) -> bool:
68+
"""Return True if ``url``'s host is a literal loopback address.
69+
70+
Used by the OpenAPI converter to detect the SSRF case where a remote spec
71+
declares ``servers: [{ url: "http://127.0.0.1:..." }]`` to redirect tool
72+
invocation at the host running the agent. Hostname-based — not a string
73+
prefix — so ``http://localhost.evil.com`` returns False.
74+
"""
75+
if not isinstance(url, str) or not url:
76+
return False
77+
78+
try:
79+
parsed = urlparse(url)
80+
except ValueError:
81+
return False
82+
83+
host = (parsed.hostname or "").lower()
84+
if not host:
85+
return False
86+
87+
if host in _LOOPBACK_HOSTNAMES:
88+
return True
89+
90+
try:
91+
return ip_address(host).is_loopback
92+
except ValueError:
93+
return False
94+
95+
6796
def ensure_secure_url(url: str, *, context: Optional[str] = None) -> None:
6897
"""Raise ``ValueError`` if ``url`` is not safe to fetch.
6998

plugins/communication_protocols/http/src/utcp_http/openapi_converter.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from utcp.data.utcp_manual import UtcpManual
2828
from utcp.data.tool import Tool, JsonSchema
2929
from utcp_http.http_call_template import HttpCallTemplate
30+
from utcp_http._security import is_loopback_url
3031

3132
class OpenApiConverter:
3233
"""REQUIRED
@@ -148,9 +149,40 @@ def convert(self) -> UtcpManual:
148149

149150
# Determine base URL: override > servers > spec_url > fallback
150151
if self._base_url_override:
152+
# Explicit override from UTCP config — caller has accepted the
153+
# trust decision, no further validation here.
151154
base_url = self._base_url_override
152155
elif self.spec.get("servers"):
153156
base_url = self.spec["servers"][0].get("url", "/")
157+
158+
# Defense in depth against issue #83 / GHSA-39j6-4867-gg4w:
159+
# a remote OpenAPI spec must not be allowed to redirect tool
160+
# invocation at the agent's own loopback interface (cloud
161+
# metadata, internal admin panels, etc.). The runtime check in
162+
# call_tool already blocks the request, but rejecting at
163+
# conversion time produces a clearer error and prevents the
164+
# malicious tools from ever entering the registry.
165+
#
166+
# We only reject when the *spec was fetched from a non-loopback
167+
# source*. A user pointing the converter at their own
168+
# localhost OpenAPI spec is allowed to declare loopback
169+
# servers, and an explicit ``base_url`` override always wins
170+
# (handled above).
171+
if (
172+
self.spec_url
173+
and not is_loopback_url(self.spec_url)
174+
and is_loopback_url(base_url)
175+
):
176+
raise ValueError(
177+
"Security error: OpenAPI spec fetched from "
178+
f"{self.spec_url!r} declares a loopback server URL "
179+
f"({base_url!r}). A remote spec is not allowed to "
180+
"redirect tool calls at the agent's own loopback "
181+
"interface — this is the SSRF pattern from "
182+
"GHSA-39j6-4867-gg4w. If you trust this spec, set "
183+
"the call template's ``base_url`` override "
184+
"explicitly to bypass this check."
185+
)
154186
elif self.spec_url:
155187
parsed_url = urlparse(self.spec_url)
156188
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"

plugins/communication_protocols/http/tests/test_security.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,99 @@ def test_ensure_secure_url_raises_with_context() -> None:
6565
def test_ensure_secure_url_passes_silently_for_valid_url() -> None:
6666
# Should not raise.
6767
ensure_secure_url("https://example.com/v1/tool", context="manual discovery")
68+
69+
70+
# --- is_loopback_url --------------------------------------------------------
71+
72+
from utcp_http._security import is_loopback_url
73+
74+
75+
@pytest.mark.parametrize(
76+
"url",
77+
[
78+
"http://localhost/x",
79+
"http://localhost:9090/x",
80+
"http://127.0.0.1/x",
81+
"http://127.0.0.1:8080/x",
82+
"http://[::1]:9090/x",
83+
"https://localhost/x",
84+
],
85+
)
86+
def test_loopback_urls_detected(url: str) -> None:
87+
assert is_loopback_url(url) is True
88+
89+
90+
@pytest.mark.parametrize(
91+
"url",
92+
[
93+
"https://example.com/x",
94+
"http://10.0.0.5/x",
95+
"http://example.com/x",
96+
# The historical hostname-prefix bypass must NOT register as loopback.
97+
"http://localhost.evil.com/x",
98+
"http://127.0.0.1.attacker.example/x",
99+
"",
100+
"not-a-url",
101+
],
102+
)
103+
def test_non_loopback_urls_rejected(url: str) -> None:
104+
assert is_loopback_url(url) is False
105+
106+
107+
# --- OpenAPI converter SSRF defense -----------------------------------------
108+
109+
from utcp_http.openapi_converter import OpenApiConverter
110+
111+
112+
def _spec_with_server(server_url: str) -> dict:
113+
return {
114+
"openapi": "3.0.0",
115+
"info": {"title": "T"},
116+
"servers": [{"url": server_url}],
117+
"paths": {
118+
"/x": {"get": {"operationId": "x", "responses": {"200": {"description": "ok"}}}}
119+
},
120+
}
121+
122+
123+
def test_converter_rejects_loopback_server_from_remote_spec() -> None:
124+
"""A remote (non-loopback) OpenAPI spec must not redirect at loopback."""
125+
converter = OpenApiConverter(
126+
_spec_with_server("http://127.0.0.1:9090"),
127+
spec_url="https://attacker.example/openapi.json",
128+
)
129+
with pytest.raises(ValueError) as exc:
130+
converter.convert()
131+
assert "loopback" in str(exc.value).lower()
132+
assert "GHSA-39j6-4867-gg4w" in str(exc.value)
133+
134+
135+
def test_converter_allows_loopback_server_from_loopback_spec() -> None:
136+
"""Local-dev case: spec from localhost can declare a localhost server."""
137+
converter = OpenApiConverter(
138+
_spec_with_server("http://127.0.0.1:9090"),
139+
spec_url="http://localhost:8000/openapi.json",
140+
)
141+
manual = converter.convert()
142+
assert len(manual.tools) == 1
143+
144+
145+
def test_converter_allows_explicit_base_url_override() -> None:
146+
"""If the user explicitly overrides base_url, we trust the user."""
147+
converter = OpenApiConverter(
148+
_spec_with_server("http://127.0.0.1:9090"),
149+
spec_url="https://attacker.example/openapi.json",
150+
base_url="http://127.0.0.1:9090",
151+
)
152+
manual = converter.convert()
153+
assert len(manual.tools) == 1
154+
155+
156+
def test_converter_allows_remote_server_from_remote_spec() -> None:
157+
"""Normal case: remote spec, remote server."""
158+
converter = OpenApiConverter(
159+
_spec_with_server("https://api.example.com"),
160+
spec_url="https://api.example.com/openapi.json",
161+
)
162+
manual = converter.convert()
163+
assert len(manual.tools) == 1

0 commit comments

Comments
 (0)