@@ -173,66 +173,71 @@ pub async fn oauth_authorize(
173173 ) ;
174174 }
175175
176- // Resolve and validate client (CIMD or traditional)
177- {
176+ // Resolve and validate client (CIMD or traditional).
177+ //
178+ // IMPORTANT: clone the service handle out and DROP the state lock before
179+ // `resolve_client()` — CIMD client ids resolve via an outbound HTTP fetch
180+ // (10 s timeout), and `oauth_middleware` takes this same write-preferring
181+ // RwLock on every MCP request, so a read guard held across the fetch plus
182+ // one queued writer would stall all MCP traffic.
183+ let client_metadata_service = {
178184 let gateway_state = state. read ( ) . await ;
185+ gateway_state. client_metadata_service_arc ( )
186+ } ;
187+ let Some ( client_metadata_service) = client_metadata_service else {
188+ error ! ( "[OAuth] ClientMetadataService not available" ) ;
189+ return oauth_error_redirect (
190+ & params. redirect_uri ,
191+ "server_error" ,
192+ "Service not available" ,
193+ params. state . as_deref ( ) ,
194+ ) ;
195+ } ;
179196
180- let client_metadata_service = match gateway_state. client_metadata_service ( ) {
181- Some ( s) => s,
182- None => {
183- error ! ( "[OAuth] ClientMetadataService not available" ) ;
184- return oauth_error_redirect (
185- & params. redirect_uri ,
186- "server_error" ,
187- "Service not available" ,
188- params. state . as_deref ( ) ,
189- ) ;
190- }
191- } ;
192-
193- // Resolve client (handles CIMD URL or traditional client_id)
194- let client = match client_metadata_service
195- . resolve_client ( & params. client_id )
196- . await
197- {
198- Ok ( Some ( c) ) => c,
199- Ok ( None ) => {
200- warn ! ( "[OAuth] Unknown client_id: {}" , params. client_id) ;
201- return oauth_error_redirect (
202- & params. redirect_uri ,
203- "invalid_client" ,
204- "Client not registered" ,
205- params. state . as_deref ( ) ,
197+ // Resolve client (handles CIMD URL or traditional client_id). The same
198+ // resolution also yields the consent page's display name — resolve once.
199+ let display_name = match client_metadata_service
200+ . resolve_client ( & params. client_id )
201+ . await
202+ {
203+ Ok ( Some ( client) ) => {
204+ // Validate redirect_uri against resolved client.
205+ // Per RFC 8252 §7.3, loopback redirect URIs are matched ignoring the port,
206+ // since native public clients use an ephemeral OS-assigned port at request
207+ // time that may differ from the one captured at DCR.
208+ if !is_redirect_uri_allowed ( & client. redirect_uris , & params. redirect_uri ) {
209+ warn ! (
210+ "[OAuth] Invalid redirect_uri for client: {} (expected one of: {:?})" ,
211+ params. redirect_uri, client. redirect_uris
206212 ) ;
207- }
208- Err ( e) => {
209- error ! ( "[OAuth] Client resolution failed: {}" , e) ;
210213 return oauth_error_redirect (
211214 & params. redirect_uri ,
212- "server_error " ,
213- "Client resolution error " ,
215+ "invalid_redirect_uri " ,
216+ "Redirect URI not registered for this client " ,
214217 params. state . as_deref ( ) ,
215218 ) ;
216219 }
217- } ;
218-
219- // Validate redirect_uri against resolved client.
220- // Per RFC 8252 §7.3, loopback redirect URIs are matched ignoring the port,
221- // since native public clients use an ephemeral OS-assigned port at request
222- // time that may differ from the one captured at DCR.
223- if !is_redirect_uri_allowed ( & client. redirect_uris , & params. redirect_uri ) {
224- warn ! (
225- "[OAuth] Invalid redirect_uri for client: {} (expected one of: {:?})" ,
226- params. redirect_uri, client. redirect_uris
220+ client. client_name
221+ }
222+ Ok ( None ) => {
223+ warn ! ( "[OAuth] Unknown client_id: {}" , params. client_id) ;
224+ return oauth_error_redirect (
225+ & params. redirect_uri ,
226+ "invalid_client" ,
227+ "Client not registered" ,
228+ params. state . as_deref ( ) ,
227229 ) ;
230+ }
231+ Err ( e) => {
232+ error ! ( "[OAuth] Client resolution failed: {}" , e) ;
228233 return oauth_error_redirect (
229234 & params. redirect_uri ,
230- "invalid_redirect_uri " ,
231- "Redirect URI not registered for this client " ,
235+ "server_error " ,
236+ "Client resolution error " ,
232237 params. state . as_deref ( ) ,
233238 ) ;
234239 }
235- }
240+ } ;
236241
237242 // PKCE is required for public clients
238243 if params. code_challenge . is_none ( ) {
@@ -262,19 +267,6 @@ pub async fn oauth_authorize(
262267 params. client_id
263268 ) ;
264269
265- // Get client display name from metadata service for new clients
266- let display_name = {
267- let gateway_state = state. read ( ) . await ;
268- if let Some ( service) = gateway_state. client_metadata_service ( ) {
269- match service. resolve_client ( & params. client_id ) . await {
270- Ok ( Some ( client) ) => client. client_name ,
271- _ => "Unknown Application" . to_string ( ) ,
272- }
273- } else {
274- "Unknown Application" . to_string ( )
275- }
276- } ;
277-
278270 // Store pending authorization request with expiration (5 minutes)
279271 let request_id = uuid:: Uuid :: new_v4 ( ) . to_string ( ) ;
280272 let expires_at = std:: time:: SystemTime :: now ( )
@@ -323,6 +315,12 @@ pub async fn oauth_authorize(
323315
324316 let app_name = branding:: DISPLAY_NAME ;
325317
318+ // HTML-escape the client-supplied display name before interpolating it
319+ // into the consent page — DCR/CIMD `client_name` is attacker-controlled
320+ // (reflected XSS otherwise). The raw name stays on the pending
321+ // authorization for the desktop UI, which renders it as text via React.
322+ let display_name_html = html_escape_text ( & display_name) ;
323+
326324 // HTML page that triggers the deep link
327325 // The page shows a brief message while the app opens
328326 // Industry standard: Don't auto-close, let user close after approval
@@ -435,7 +433,7 @@ pub async fn oauth_authorize(
435433 </p>
436434
437435 <div class="client-info">
438- <div class="client-name">{display_name }</div>
436+ <div class="client-name">{display_name_html }</div>
439437 <div class="client-id">wants to connect</div>
440438 </div>
441439
@@ -473,6 +471,24 @@ pub async fn oauth_authorize(
473471 axum:: response:: Html ( html) . into_response ( )
474472}
475473
474+ /// Minimal HTML entity escaping for untrusted text interpolated into
475+ /// gateway-served HTML. Covers every character that can break out of a
476+ /// text node or a double-quoted attribute value.
477+ fn html_escape_text ( s : & str ) -> String {
478+ let mut out = String :: with_capacity ( s. len ( ) ) ;
479+ for c in s. chars ( ) {
480+ match c {
481+ '&' => out. push_str ( "&" ) ,
482+ '<' => out. push_str ( "<" ) ,
483+ '>' => out. push_str ( ">" ) ,
484+ '"' => out. push_str ( """ ) ,
485+ '\'' => out. push_str ( "'" ) ,
486+ _ => out. push ( c) ,
487+ }
488+ }
489+ out
490+ }
491+
476492/// Helper to create OAuth error redirect
477493fn oauth_error_redirect (
478494 redirect_uri : & str ,
0 commit comments