Skip to content

Commit 61df48c

Browse files
authored
Merge pull request #87 from universal-tool-calling-protocol/dev
Security updates
2 parents 4ed0a48 + 2cc003b commit 61df48c

17 files changed

Lines changed: 2817 additions & 90 deletions

plugins/communication_protocols/gql/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "utcp-gql"
7-
version = "1.1.0"
7+
version = "1.1.3"
88
authors = [
99
{ name = "UTCP Contributors" },
1010
]
@@ -14,6 +14,7 @@ requires-python = ">=3.10"
1414
dependencies = [
1515
"pydantic>=2.0",
1616
"gql>=3.0",
17+
"aiohttp>=3.8",
1718
"utcp>=1.1"
1819
]
1920
classifiers = [
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
"""URL validation for the GraphQL communication protocol.
2+
3+
Mirror of ``utcp_http._security`` -- intentionally duplicated rather
4+
than cross-plugin-imported so ``utcp-gql`` does not gain a runtime
5+
dependency on ``utcp-http``. Keep the two files in sync when changing
6+
the validator behavior. Backs GHSA-ppx3-28rw-8fpf (the original CVE
7+
fix did not reach this plugin) and GHSA-9qhg-99ww-9mqc (redirect
8+
SSRF on the GraphQL endpoint).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import re
14+
from contextlib import asynccontextmanager
15+
from ipaddress import IPv6Address, ip_address
16+
from typing import Any, AsyncIterator, Dict, Optional
17+
from urllib.parse import urljoin, urlparse
18+
19+
# Hostnames considered safe to talk to over plain HTTP.
20+
_LOOPBACK_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
21+
22+
23+
def is_secure_url(url: str) -> bool:
24+
"""Return True if ``url`` is safe to fetch from a UTCP HTTP protocol.
25+
26+
Allowed:
27+
- Any ``https://`` URL.
28+
- ``http://`` URLs whose host is exactly ``localhost``, ``127.0.0.1``,
29+
or ``::1``.
30+
31+
Disallowed:
32+
- Plain ``http://`` to any other host (MITM exposure).
33+
- URLs whose hostname *starts* with ``localhost`` / ``127.0.0.1`` but
34+
isn't actually loopback (e.g. ``http://localhost.evil.com``,
35+
``http://127.0.0.1.attacker.example``). The earlier ``startswith``
36+
check let these through.
37+
- Anything without a scheme/host (file://, gopher://, javascript:, ...).
38+
"""
39+
if not isinstance(url, str) or not url:
40+
return False
41+
42+
try:
43+
parsed = urlparse(url)
44+
except ValueError:
45+
return False
46+
47+
scheme = (parsed.scheme or "").lower()
48+
if scheme not in {"http", "https"}:
49+
return False
50+
51+
host = (parsed.hostname or "").lower()
52+
if not host:
53+
return False
54+
55+
if scheme == "https":
56+
return True
57+
58+
# http:// is only allowed for loopback.
59+
if host in _LOOPBACK_HOSTNAMES:
60+
return True
61+
62+
# Catch any other literal loopback IP that urlparse normalised
63+
# (e.g. ``http://127.000.000.001``).
64+
try:
65+
return ip_address(host).is_loopback
66+
except ValueError:
67+
return False
68+
69+
70+
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.
74+
"""
75+
if host in {"0.0.0.0", "::"}:
76+
return True
77+
try:
78+
addr = ip_address(host)
79+
except ValueError:
80+
return False
81+
if addr.is_loopback:
82+
return True
83+
if isinstance(addr, IPv6Address):
84+
mapped = addr.ipv4_mapped
85+
if mapped is not None and mapped.is_loopback:
86+
return True
87+
return False
88+
89+
90+
def is_loopback_url(url: str) -> bool:
91+
"""Return True if ``url``'s host is a literal loopback-or-equivalent
92+
address. Hostname-based; covers ``0.0.0.0``, ``::`` and IPv4-mapped
93+
IPv6 loopback forms in addition to the obvious set.
94+
"""
95+
if not isinstance(url, str) or not url:
96+
return False
97+
98+
try:
99+
parsed = urlparse(url)
100+
except ValueError:
101+
return False
102+
103+
host = (parsed.hostname or "").lower()
104+
if not host:
105+
return False
106+
107+
if host in _LOOPBACK_HOSTNAMES:
108+
return True
109+
110+
return _ip_is_loopback_like(host)
111+
112+
113+
def ensure_secure_url(url: str, *, context: Optional[str] = None) -> None:
114+
"""Raise ``ValueError`` if ``url`` is not safe to fetch.
115+
116+
``context`` is a short label (``"manual discovery"``, ``"tool invocation"``,
117+
etc.) included in the error so log readers can tell which trust boundary
118+
was breached.
119+
"""
120+
if is_secure_url(url):
121+
return
122+
123+
where = f" during {context}" if context else ""
124+
raise ValueError(
125+
f"Security error{where}: URL must use HTTPS or be a literal loopback "
126+
f"address (localhost / 127.0.0.1 / ::1). Got: {url!r}. "
127+
"Plain HTTP to any other host is rejected to prevent MITM attacks "
128+
"and SSRF into internal services."
129+
)
130+
131+
132+
# HTTP statuses where the server expects the client to re-issue the request
133+
# against the URL given in the ``Location`` header. 303 forces a GET; the
134+
# rest preserve the original method.
135+
_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
136+
137+
138+
_AUTH_SENSITIVE_HEADERS = frozenset({
139+
"authorization",
140+
"proxy-authorization",
141+
"cookie",
142+
"www-authenticate",
143+
"x-api-key",
144+
"api-key",
145+
"x-auth-token",
146+
"x-access-token",
147+
"x-csrf-token",
148+
"x-xsrf-token",
149+
"x-amz-security-token",
150+
"x-goog-api-key",
151+
"x_api_key",
152+
"api_key",
153+
"x_auth_token",
154+
"x_access_token",
155+
"x_csrf_token",
156+
"x_xsrf_token",
157+
"apikey",
158+
"xapikey",
159+
"authtoken",
160+
"xauthtoken",
161+
"accesstoken",
162+
"xaccesstoken",
163+
"bearertoken",
164+
"sessionid",
165+
"csrftoken",
166+
"xsrftoken",
167+
})
168+
169+
170+
_AUTH_HEADER_REGEX = re.compile(
171+
r"(?:(?:^|[-_])"
172+
r"(?:auth|authn|authz|token|key|secret|bearer|session|sid|"
173+
r"api[-_]?key|jwt|csrf|xsrf)"
174+
r"(?:[-_]|$))"
175+
r"|"
176+
r"(?:apikey|authtoken|accesstoken|bearertoken|sessionid|"
177+
r"csrftoken|xsrftoken|xapikey|xauthtoken|xaccesstoken|xapitoken)",
178+
re.IGNORECASE,
179+
)
180+
181+
182+
def _header_is_auth_sensitive(name: str) -> bool:
183+
if not isinstance(name, str):
184+
return False
185+
lower = name.lower()
186+
if lower in _AUTH_SENSITIVE_HEADERS:
187+
return True
188+
return _AUTH_HEADER_REGEX.search(lower) is not None
189+
190+
191+
_DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
192+
193+
194+
def _effective_port(scheme: str, parsed_port: Optional[int]) -> Optional[int]:
195+
if parsed_port is not None:
196+
return parsed_port
197+
return _DEFAULT_PORTS.get((scheme or "").lower())
198+
199+
200+
def _same_origin(a: str, b: str) -> bool:
201+
"""Return True iff URLs ``a`` and ``b`` share scheme+host+port.
202+
203+
Returns ``False`` on any parse failure, including
204+
``urlparse(...).port`` raising for an out-of-range port -- a
205+
bogus ``Location`` is treated as cross-origin so credentials
206+
are scrubbed instead of letting the ``ValueError`` escape.
207+
"""
208+
try:
209+
pa, pb = urlparse(a), urlparse(b)
210+
sa = (pa.scheme or "").lower()
211+
sb = (pb.scheme or "").lower()
212+
if not sa or not sb:
213+
return False
214+
if sa != sb:
215+
return False
216+
if (pa.hostname or "").lower() != (pb.hostname or "").lower():
217+
return False
218+
return _effective_port(sa, pa.port) == _effective_port(sb, pb.port)
219+
except ValueError:
220+
return False
221+
222+
223+
def _scrub_cross_origin_credentials(kwargs: dict) -> None:
224+
"""Strip auth-bearing kwargs in place when crossing origins.
225+
226+
Mirrors ``utcp_http._security._scrub_cross_origin_credentials`` --
227+
drops auth-looking headers, ``auth=`` / ``proxy_auth=``,
228+
``cookies``, ``params``, and the request body (``json`` /
229+
``data``) so 307/308 redirects cannot resend an OAuth POST body
230+
to a new origin.
231+
"""
232+
headers = kwargs.get("headers")
233+
if headers is not None:
234+
scrubbed: Dict[str, Any] = {}
235+
for k, v in dict(headers).items():
236+
if _header_is_auth_sensitive(k):
237+
continue
238+
scrubbed[k] = v
239+
kwargs["headers"] = scrubbed
240+
241+
kwargs.pop("auth", None)
242+
kwargs.pop("proxy_auth", None)
243+
kwargs.pop("cookies", None)
244+
kwargs.pop("params", None)
245+
kwargs.pop("json", None)
246+
kwargs.pop("data", None)
247+
248+
249+
@asynccontextmanager
250+
async def safe_request_with_redirects(
251+
session: Any,
252+
method: str,
253+
url: str,
254+
*,
255+
context: str,
256+
max_redirects: int = 5,
257+
**kwargs: Any,
258+
) -> AsyncIterator[Any]:
259+
"""Issue an aiohttp request that re-validates every redirect hop.
260+
261+
Closes the residual SSRF window left by ``ensure_secure_url`` (which
262+
only inspects the initial URL): aiohttp by default follows 3xx
263+
redirects without rechecking, so an attacker-controlled server could
264+
302 the client into ``http://169.254.169.254/...`` (cloud metadata)
265+
or any internal HTTP service and the response body would be handed
266+
back to the caller. Backs GHSA-9qhg-99ww-9mqc.
267+
268+
Behavior:
269+
* Calls ``ensure_secure_url(url, context=context)`` on the initial
270+
URL.
271+
* Disables aiohttp's auto-follow (``allow_redirects=False``).
272+
* On a 3xx response with a ``Location`` header, resolves the
273+
target against the current URL and runs ``ensure_secure_url``
274+
on it before issuing the next hop. Rejection raises and the
275+
redirect chain is aborted with the connection released.
276+
* Caps the chain at ``max_redirects`` hops. Exceeding that raises
277+
``RuntimeError``.
278+
* Mirrors RFC 7231 method semantics: 303 forces ``GET`` and drops
279+
any request body; 301/302/307/308 preserve method and body.
280+
281+
Usage:
282+
```python
283+
async with safe_request_with_redirects(
284+
session, "GET", url, context="tool invocation", params=...
285+
) as response:
286+
response.raise_for_status()
287+
...
288+
```
289+
"""
290+
ensure_secure_url(url, context=context)
291+
# We control redirect behavior ourselves; refuse to let callers override.
292+
kwargs.pop("allow_redirects", None)
293+
294+
current_url = url
295+
current_method = method
296+
hops = 0
297+
final_response = None
298+
299+
try:
300+
while True:
301+
response = await session.request(
302+
current_method,
303+
current_url,
304+
allow_redirects=False,
305+
**kwargs,
306+
)
307+
if response.status not in _REDIRECT_STATUSES:
308+
final_response = response
309+
break
310+
311+
location = response.headers.get("Location")
312+
if not location:
313+
# 3xx with no Location header — nothing to follow. Let
314+
# the caller handle the unusual response.
315+
final_response = response
316+
break
317+
318+
if hops >= max_redirects:
319+
response.release()
320+
raise RuntimeError(
321+
f"Too many redirects (>{max_redirects}) during {context} "
322+
f"starting from {url!r}."
323+
)
324+
325+
next_url = urljoin(current_url, location)
326+
try:
327+
ensure_secure_url(
328+
next_url, context=f"{context} (redirect target)"
329+
)
330+
except Exception:
331+
response.release()
332+
raise
333+
334+
response.release()
335+
336+
# Strip auth-bearing kwargs on cross-origin redirect.
337+
if not _same_origin(current_url, next_url):
338+
_scrub_cross_origin_credentials(kwargs)
339+
340+
if response.status == 303:
341+
current_method = "GET"
342+
kwargs.pop("json", None)
343+
kwargs.pop("data", None)
344+
current_url = next_url
345+
hops += 1
346+
347+
yield final_response
348+
finally:
349+
if final_response is not None:
350+
final_response.release()

0 commit comments

Comments
 (0)