@@ -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
0 commit comments