Skip to content

Commit 29c0d1b

Browse files
authored
Merge pull request #89 from universal-tool-calling-protocol/dev
security fixes
2 parents 61df48c + f6f51e9 commit 29c0d1b

10 files changed

Lines changed: 280 additions & 44 deletions

File tree

plugins/communication_protocols/gql/pyproject.toml

Lines changed: 1 addition & 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.3"
7+
version = "1.1.4"
88
authors = [
99
{ name = "UTCP Contributors" },
1010
]

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,10 @@ def _same_origin(a: str, b: str) -> bool:
220220
return False
221221

222222

223-
def _scrub_cross_origin_credentials(kwargs: dict) -> None:
223+
def _scrub_cross_origin_credentials(
224+
kwargs: dict,
225+
extra_auth_header_names: Optional[frozenset] = None,
226+
) -> None:
224227
"""Strip auth-bearing kwargs in place when crossing origins.
225228
226229
Mirrors ``utcp_http._security._scrub_cross_origin_credentials`` --
@@ -229,12 +232,15 @@ def _scrub_cross_origin_credentials(kwargs: dict) -> None:
229232
``data``) so 307/308 redirects cannot resend an OAuth POST body
230233
to a new origin.
231234
"""
235+
extra = extra_auth_header_names or frozenset()
232236
headers = kwargs.get("headers")
233237
if headers is not None:
234238
scrubbed: Dict[str, Any] = {}
235239
for k, v in dict(headers).items():
236240
if _header_is_auth_sensitive(k):
237241
continue
242+
if isinstance(k, str) and k.lower() in extra:
243+
continue
238244
scrubbed[k] = v
239245
kwargs["headers"] = scrubbed
240246

@@ -254,6 +260,7 @@ async def safe_request_with_redirects(
254260
*,
255261
context: str,
256262
max_redirects: int = 5,
263+
auth_header_names: Optional[Any] = None,
257264
**kwargs: Any,
258265
) -> AsyncIterator[Any]:
259266
"""Issue an aiohttp request that re-validates every redirect hop.
@@ -291,6 +298,12 @@ async def safe_request_with_redirects(
291298
# We control redirect behavior ourselves; refuse to let callers override.
292299
kwargs.pop("allow_redirects", None)
293300

301+
extra_auth_header_names = frozenset(
302+
n.lower()
303+
for n in (auth_header_names or [])
304+
if isinstance(n, str)
305+
)
306+
294307
current_url = url
295308
current_method = method
296309
hops = 0
@@ -335,7 +348,9 @@ async def safe_request_with_redirects(
335348

336349
# Strip auth-bearing kwargs on cross-origin redirect.
337350
if not _same_origin(current_url, next_url):
338-
_scrub_cross_origin_credentials(kwargs)
351+
_scrub_cross_origin_credentials(
352+
kwargs, extra_auth_header_names
353+
)
339354

340355
if response.status == 303:
341356
current_method = "GET"

plugins/communication_protocols/http/pyproject.toml

Lines changed: 1 addition & 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-http"
7-
version = "1.1.6"
7+
version = "1.1.7"
88
authors = [
99
{ name = "UTCP Contributors" },
1010
]

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,10 @@ def _same_origin(a: str, b: str) -> bool:
284284
return False
285285

286286

287-
def _scrub_cross_origin_credentials(kwargs: dict) -> None:
287+
def _scrub_cross_origin_credentials(
288+
kwargs: dict,
289+
extra_auth_header_names: Optional[frozenset] = None,
290+
) -> None:
288291
"""Strip auth-bearing kwargs in place when crossing origins.
289292
290293
Aligns the redirect helper with browser / requests / curl
@@ -314,6 +317,7 @@ def _scrub_cross_origin_credentials(kwargs: dict) -> None:
314317
Callers invoke this BEFORE issuing the next hop, only when the
315318
redirect target's origin differs from the current URL's origin.
316319
"""
320+
extra = extra_auth_header_names or frozenset()
317321
headers = kwargs.get("headers")
318322
if headers is not None:
319323
# Build a new dict so we never mutate the caller's headers
@@ -322,6 +326,11 @@ def _scrub_cross_origin_credentials(kwargs: dict) -> None:
322326
for k, v in dict(headers).items():
323327
if _header_is_auth_sensitive(k):
324328
continue
329+
# Strip caller-configured custom auth header names
330+
# (e.g. ``ApiKeyAuth`` with ``var_name="X-MyApp"``)
331+
# that don't match the auth-pattern regex on their own.
332+
if isinstance(k, str) and k.lower() in extra:
333+
continue
325334
scrubbed[k] = v
326335
kwargs["headers"] = scrubbed
327336

@@ -351,6 +360,7 @@ async def safe_request_with_redirects(
351360
*,
352361
context: str,
353362
max_redirects: int = 5,
363+
auth_header_names: Optional[Any] = None,
354364
**kwargs: Any,
355365
) -> AsyncIterator[Any]:
356366
"""Issue an aiohttp request that re-validates every redirect hop.
@@ -388,6 +398,22 @@ async def safe_request_with_redirects(
388398
# We control redirect behavior ourselves; refuse to let callers override.
389399
kwargs.pop("allow_redirects", None)
390400

401+
# Pull caller-configured auth header names so the cross-origin
402+
# scrub can strip them too. Private contract -- callers attach
403+
# this via ``_apply_auth`` to declare which header names they
404+
# populated with a secret. Never sent on the wire.
405+
# ``auth_header_names`` is the explicit declaration of which
406+
# header names the caller populated with a secret. Used to extend
407+
# the cross-origin scrub beyond the canonical set so a
408+
# custom-named API-key header (e.g. ``X-MyApp``) configured via
409+
# ``ApiKeyAuth`` / ``OAuth2UserAuth`` is also stripped on
410+
# cross-origin redirect.
411+
extra_auth_header_names = frozenset(
412+
n.lower()
413+
for n in (auth_header_names or [])
414+
if isinstance(n, str)
415+
)
416+
391417
current_url = url
392418
current_method = method
393419
hops = 0
@@ -437,7 +463,9 @@ async def safe_request_with_redirects(
437463
# API key would be forwarded along. Mirrors browser /
438464
# requests / curl behaviour.
439465
if not _same_origin(current_url, next_url):
440-
_scrub_cross_origin_credentials(kwargs)
466+
_scrub_cross_origin_credentials(
467+
kwargs, extra_auth_header_names
468+
)
441469

442470
if response.status == 303:
443471
current_method = "GET"

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

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,37 +76,65 @@ def __init__(self, logger: Optional[Callable[[str], None]] = None):
7676
self._session: Optional[aiohttp.ClientSession] = None
7777
self._oauth_tokens: Dict[str, Dict[str, Any]] = {}
7878

79+
@staticmethod
80+
def _assert_no_crlf(value: Optional[str], field_name: str) -> None:
81+
"""Refuse CR/LF in attacker-influenceable strings that will land
82+
in HTTP headers. aiohttp blocks these at request time, but
83+
keeping the trust boundary inside UTCP means a transport swap
84+
cannot silently regress.
85+
"""
86+
if not isinstance(value, str):
87+
return
88+
if "\r" in value or "\n" in value:
89+
raise ValueError(
90+
f"Refusing to construct request: {field_name} contains CR/LF, "
91+
f"which would enable HTTP header injection."
92+
)
93+
7994
def _apply_auth(self, provider: HttpCallTemplate, headers: Dict[str, str], query_params: Dict[str, Any]) -> tuple:
8095
"""Apply authentication to the request based on the provider's auth configuration.
81-
96+
8297
Returns:
83-
tuple: (auth_obj, cookies) where auth_obj is for aiohttp basic auth and cookies is a dict
98+
tuple ``(auth_obj, cookies, auth_header_names)``:
99+
* ``auth_obj``: aiohttp BasicAuth for HTTP basic, or None.
100+
* ``cookies``: dict of cookies to attach.
101+
* ``auth_header_names``: list of header names that
102+
received a secret. Threaded into
103+
``safe_request_with_redirects`` so a custom-named auth
104+
header (e.g. ``ApiKeyAuth(var_name="X-MyApp")``) is
105+
also stripped on cross-origin redirect.
84106
"""
85107
auth = None
86108
cookies = {}
87-
109+
auth_header_names: List[str] = []
110+
88111
if provider.auth:
89112
if isinstance(provider.auth, ApiKeyAuth):
90113
if provider.auth.api_key:
114+
self._assert_no_crlf(provider.auth.var_name, "ApiKeyAuth.var_name")
91115
if provider.auth.location == "header":
92116
headers[provider.auth.var_name] = provider.auth.api_key
117+
auth_header_names.append(provider.auth.var_name)
93118
elif provider.auth.location == "query":
94119
query_params[provider.auth.var_name] = provider.auth.api_key
95120
elif provider.auth.location == "cookie":
96121
cookies[provider.auth.var_name] = provider.auth.api_key
97122
else:
98123
logger.error("API key not found for ApiKeyAuth.")
99124
raise ValueError("API key for ApiKeyAuth not found.")
100-
125+
101126
elif isinstance(provider.auth, BasicAuth):
102127
auth = AiohttpBasicAuth(provider.auth.username, provider.auth.password)
103-
128+
104129
elif isinstance(provider.auth, OAuth2Auth):
105130
# OAuth2 tokens are always sent in the Authorization header
106-
# We'll handle this separately since it requires async token retrieval
107-
pass
108-
109-
return auth, cookies
131+
# We'll handle this separately since it requires async token retrieval.
132+
# We DO declare ``Authorization`` here so the scrubber treats
133+
# the resulting bearer header as cross-origin-sensitive even
134+
# if it slipped past the regex.
135+
auth_header_names.append("Authorization")
136+
137+
return auth, cookies, auth_header_names
110138

111139
async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult:
112140
"""REQUIRED
@@ -136,7 +164,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R
136164
query_params = {}
137165

138166
# Handle authentication
139-
auth, cookies = self._apply_auth(manual_call_template, request_headers, query_params)
167+
auth, cookies, auth_header_names = self._apply_auth(manual_call_template, request_headers, query_params)
140168

141169
# Handle OAuth2 separately since it requires async token retrieval
142170
if manual_call_template.auth and isinstance(manual_call_template.auth, OAuth2Auth):
@@ -180,6 +208,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R
180208
data=data,
181209
cookies=cookies,
182210
timeout=aiohttp.ClientTimeout(total=10.0),
211+
auth_header_names=auth_header_names,
183212
) as response:
184213
response.raise_for_status() # Raise exception for 4XX/5XX responses
185214

@@ -288,7 +317,7 @@ async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], too
288317
query_params = remaining_args
289318

290319
# Handle authentication
291-
auth, cookies = self._apply_auth(tool_call_template, request_headers, query_params)
320+
auth, cookies, auth_header_names = self._apply_auth(tool_call_template, request_headers, query_params)
292321

293322
# Handle OAuth2 separately since it requires async token retrieval
294323
if tool_call_template.auth and isinstance(tool_call_template.auth, OAuth2Auth):
@@ -328,6 +357,7 @@ async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], too
328357
data=data,
329358
cookies=cookies,
330359
timeout=aiohttp.ClientTimeout(total=30.0),
360+
auth_header_names=auth_header_names,
331361
) as response:
332362
response.raise_for_status()
333363

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

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,37 +38,50 @@ class SseCommunicationProtocol(CommunicationProtocol):
3838
def __init__(self, logger: Optional[Callable[[str], None]] = None):
3939
self._oauth_tokens: Dict[str, Dict[str, Any]] = {}
4040

41+
@staticmethod
42+
def _assert_no_crlf(value: Optional[str], field_name: str) -> None:
43+
if not isinstance(value, str):
44+
return
45+
if "\r" in value or "\n" in value:
46+
raise ValueError(
47+
f"Refusing to construct request: {field_name} contains CR/LF, "
48+
f"which would enable HTTP header injection."
49+
)
50+
4151
def _apply_auth(self, provider: SseCallTemplate, headers: Dict[str, str], query_params: Dict[str, Any]) -> tuple:
4252
"""Apply authentication to the request based on the provider's auth configuration.
43-
53+
4454
Returns:
45-
tuple: (auth_obj, cookies) where auth_obj is for aiohttp basic auth and cookies is a dict
55+
tuple ``(auth_obj, cookies, auth_header_names)``.
4656
"""
4757
auth = None
4858
cookies = {}
49-
59+
auth_header_names: List[str] = []
60+
5061
if provider.auth:
5162
if isinstance(provider.auth, ApiKeyAuth):
5263
if provider.auth.api_key:
64+
self._assert_no_crlf(provider.auth.var_name, "ApiKeyAuth.var_name")
5365
if provider.auth.location == "header":
5466
headers[provider.auth.var_name] = provider.auth.api_key
67+
auth_header_names.append(provider.auth.var_name)
5568
elif provider.auth.location == "query":
5669
query_params[provider.auth.var_name] = provider.auth.api_key
5770
elif provider.auth.location == "cookie":
5871
cookies[provider.auth.var_name] = provider.auth.api_key
5972
else:
6073
logger.error("API key not found for ApiKeyAuth.")
6174
raise ValueError("API key for ApiKeyAuth not found.")
62-
75+
6376
elif isinstance(provider.auth, BasicAuth):
6477
auth = AiohttpBasicAuth(provider.auth.username, provider.auth.password)
65-
78+
6679
elif isinstance(provider.auth, OAuth2Auth):
67-
# OAuth2 tokens are always sent in the Authorization header
68-
# We'll handle this separately since it requires async token retrieval
69-
pass
70-
71-
return auth, cookies
80+
# OAuth2 tokens are always sent in the Authorization header.
81+
# Declared so cross-origin scrub recognises it.
82+
auth_header_names.append("Authorization")
83+
84+
return auth, cookies, auth_header_names
7285

7386
async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult:
7487
"""REQUIRED
@@ -90,7 +103,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R
90103

91104
# Handle authentication
92105
query_params: Dict[str, Any] = {}
93-
auth, cookies = self._apply_auth(manual_call_template, request_headers, query_params)
106+
auth, cookies, auth_header_names = self._apply_auth(manual_call_template, request_headers, query_params)
94107

95108
# Handle OAuth2 separately as it's async
96109
if isinstance(manual_call_template.auth, OAuth2Auth):
@@ -133,6 +146,7 @@ async def register_manual(self, caller, manual_call_template: CallTemplate) -> R
133146
json=json_data,
134147
data=data,
135148
timeout=aiohttp.ClientTimeout(total=10.0),
149+
auth_header_names=auth_header_names,
136150
) as response:
137151
response.raise_for_status()
138152
response_data = await response.json()
@@ -199,7 +213,11 @@ async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str,
199213
query_params = remaining_args
200214

201215
# Handle authentication
202-
auth, cookies = self._apply_auth(tool_call_template, request_headers, query_params)
216+
# ``auth_header_names`` unused in the streaming path because
217+
# SSE handshake uses ``allow_redirects=False`` -- there is no
218+
# redirect chain to scrub. Reserved for future use if
219+
# streaming ever supports per-hop validation.
220+
auth, cookies, _auth_header_names = self._apply_auth(tool_call_template, request_headers, query_params)
203221

204222
# Handle OAuth2 separately as it's async
205223
if isinstance(tool_call_template.auth, OAuth2Auth):

0 commit comments

Comments
 (0)