@@ -211,14 +211,67 @@ fn redirect_uri_matches(registered: &str, requested: &str) -> bool {
211211 && reg_url. path ( ) == req_url. path ( )
212212}
213213
214+ fn is_loopback_redirect_uri ( uri : & str ) -> bool {
215+ let Ok ( url) = url:: Url :: parse ( uri) else {
216+ return false ;
217+ } ;
218+
219+ if url. scheme ( ) != "http" {
220+ return false ;
221+ }
222+
223+ match url. host ( ) {
224+ Some ( url:: Host :: Ipv4 ( ip) ) => ip. is_loopback ( ) ,
225+ Some ( url:: Host :: Ipv6 ( ip) ) => ip. is_loopback ( ) ,
226+ Some ( url:: Host :: Domain ( host) ) => host. eq_ignore_ascii_case ( "localhost" ) ,
227+ None => false ,
228+ }
229+ }
230+
231+ fn is_custom_scheme_redirect_uri ( uri : & str ) -> bool {
232+ let Ok ( url) = url:: Url :: parse ( uri) else {
233+ return false ;
234+ } ;
235+
236+ url. scheme ( ) != "http" && url. scheme ( ) != "https"
237+ }
238+
239+ fn is_chatgpt_connector_redirect_uri ( uri : & str ) -> bool {
240+ let Ok ( url) = url:: Url :: parse ( uri) else {
241+ return false ;
242+ } ;
243+
244+ url. scheme ( ) == "https"
245+ && matches ! ( url. host( ) , Some ( url:: Host :: Domain ( host) ) if host. eq_ignore_ascii_case( "chatgpt.com" ) )
246+ && ( url. path ( ) == "/connector/oauth" || url. path ( ) . starts_with ( "/connector/oauth/" ) )
247+ }
248+
249+ fn is_valid_registered_redirect_uri ( uri : & str ) -> bool {
250+ is_loopback_redirect_uri ( uri)
251+ || is_custom_scheme_redirect_uri ( uri)
252+ || is_chatgpt_connector_redirect_uri ( uri)
253+ }
254+
255+ fn filter_valid_redirect_uris ( uris : & [ String ] ) -> Vec < String > {
256+ let mut filtered = Vec :: new ( ) ;
257+ for uri in uris {
258+ if is_valid_registered_redirect_uri ( uri) && !filtered. contains ( uri) {
259+ filtered. push ( uri. clone ( ) ) ;
260+ }
261+ }
262+ filtered
263+ }
264+
214265/// Validate redirect URIs per RFC 8252 (OAuth 2.0 for Native Apps)
215266///
216267/// Allowed redirect URI types:
217268/// 1. Loopback: http://127.0.0.1:PORT/... or http://localhost:PORT/...
218269/// 2. Custom URL schemes: cursor://, vscode://, claude://, etc.
270+ /// 3. ChatGPT connector OAuth callback URLs:
271+ /// https://chatgpt.com/connector/oauth/...
219272///
220273/// NOT allowed (these are filtered from the request, not hard-failed):
221- /// - https:// URLs (except for confidential clients with proper secrets)
274+ /// - Other https:// URLs (except for confidential clients with proper secrets)
222275/// - http:// URLs to non-loopback addresses
223276///
224277/// Invalid URIs are silently skipped rather than rejecting the entire registration.
@@ -236,36 +289,33 @@ pub fn validate_redirect_uris(uris: &[String]) -> Result<(), DcrError> {
236289 let mut valid_count = 0 ;
237290
238291 for uri in uris {
239- let is_loopback = uri. starts_with ( "http://127.0.0.1" )
240- || uri. starts_with ( "http://localhost" )
241- || uri. starts_with ( "http://[::1]" ) ;
242-
243- // Custom URL schemes (like cursor://, vscode://) are allowed
244- // They don't start with http:// or https://
245- let is_custom_scheme = !uri. starts_with ( "http://" ) && !uri. starts_with ( "https://" ) ;
292+ let is_loopback = is_loopback_redirect_uri ( uri) ;
293+ let is_custom_scheme = is_custom_scheme_redirect_uri ( uri) ;
294+ let is_chatgpt_connector = is_chatgpt_connector_redirect_uri ( uri) ;
246295
247- if !is_loopback && !is_custom_scheme {
296+ if !is_loopback && !is_custom_scheme && !is_chatgpt_connector {
248297 // Skip invalid URIs (e.g. https://www.cursor.com/agents/mcp/oauth/callback)
249298 // rather than rejecting the entire registration — clients like Cursor send a
250299 // mix of valid and invalid URIs and only ever use the valid ones in practice.
251300 warn ! (
252- "[DCR] Skipping invalid redirect_uri: {} (must be loopback or custom scheme)" ,
301+ "[DCR] Skipping invalid redirect_uri: {} (must be loopback, custom scheme, or ChatGPT connector callback )" ,
253302 uri
254303 ) ;
255304 continue ;
256305 }
257306
258307 debug ! (
259- "[DCR] Validated redirect_uri: {} (loopback={}, custom_scheme={})" ,
260- uri, is_loopback, is_custom_scheme
308+ "[DCR] Validated redirect_uri: {} (loopback={}, custom_scheme={}, chatgpt_connector={} )" ,
309+ uri, is_loopback, is_custom_scheme, is_chatgpt_connector
261310 ) ;
262311 valid_count += 1 ;
263312 }
264313
265314 if valid_count == 0 {
266315 return Err ( DcrError :: invalid_redirect_uri (
267316 "No valid redirect_uris provided — must include at least one loopback \
268- (http://127.0.0.1 or http://localhost) or custom URL scheme (e.g., cursor://, vscode://)",
317+ (http://127.0.0.1 or http://localhost), custom URL scheme \
318+ (e.g., cursor://, vscode://), or ChatGPT connector callback",
269319 ) ) ;
270320 }
271321
@@ -284,8 +334,10 @@ pub async fn process_dcr_request(
284334 request. client_name, request. redirect_uris
285335 ) ;
286336
287- // Validate redirect URIs
337+ // Validate and filter redirect URIs. DCR clients sometimes submit a mixed list;
338+ // only registered-safe URIs are persisted and returned.
288339 validate_redirect_uris ( & request. redirect_uris ) ?;
340+ let valid_redirect_uris = filter_valid_redirect_uris ( & request. redirect_uris ) ;
289341
290342 // Check for existing client with same name (idempotent registration by client_name)
291343 let existing = repo
@@ -306,9 +358,9 @@ pub async fn process_dcr_request(
306358 . unwrap ( )
307359 . as_secs ( ) ;
308360
309- // Merge redirect URIs (accumulate - keep old URIs valid)
310- let mut merged_uris = existing. redirect_uris ;
311- for uri in & request . redirect_uris {
361+ // Merge redirect URIs (accumulate - keep old valid URIs )
362+ let mut merged_uris = filter_valid_redirect_uris ( & existing. redirect_uris ) ;
363+ for uri in & valid_redirect_uris {
312364 if !merged_uris. contains ( uri) {
313365 merged_uris. push ( uri. clone ( ) ) ;
314366 info ! (
@@ -411,7 +463,7 @@ pub async fn process_dcr_request(
411463 let client = build_inbound_client_from_request (
412464 & request,
413465 client_id. clone ( ) ,
414- request . redirect_uris . clone ( ) ,
466+ valid_redirect_uris . clone ( ) ,
415467 grant_types. clone ( ) ,
416468 response_types. clone ( ) ,
417469 token_endpoint_auth_method. clone ( ) ,
@@ -434,7 +486,7 @@ pub async fn process_dcr_request(
434486 Ok ( DcrResponse {
435487 client_id,
436488 client_name : request. client_name ,
437- redirect_uris : request . redirect_uris ,
489+ redirect_uris : valid_redirect_uris ,
438490 grant_types,
439491 response_types,
440492 token_endpoint_auth_method,
@@ -492,6 +544,14 @@ mod tests {
492544 assert ! ( validate_redirect_uris( & uris) . is_ok( ) ) ;
493545 }
494546
547+ #[ test]
548+ fn test_validate_chatgpt_connector_redirect_uri ( ) {
549+ assert ! ( validate_redirect_uris(
550+ & [ "https://chatgpt.com/connector/oauth/abc123" . to_string( ) ]
551+ )
552+ . is_ok( ) ) ;
553+ }
554+
495555 #[ test]
496556 fn test_all_invalid_uris_fail ( ) {
497557 let uris = vec ! [
@@ -501,6 +561,23 @@ mod tests {
501561 assert ! ( validate_redirect_uris( & uris) . is_err( ) ) ;
502562 }
503563
564+ #[ test]
565+ fn test_filter_redirect_uris_drops_invalid_entries ( ) {
566+ let uris = vec ! [
567+ "cursor://anysphere.cursor-mcp/oauth/callback" . to_string( ) ,
568+ "https://www.cursor.com/agents/mcp/oauth/callback" . to_string( ) ,
569+ "https://chatgpt.com/connector/oauth/abc123" . to_string( ) ,
570+ "http://localhost.evil/callback" . to_string( ) ,
571+ ] ;
572+ assert_eq ! (
573+ filter_valid_redirect_uris( & uris) ,
574+ vec![
575+ "cursor://anysphere.cursor-mcp/oauth/callback" . to_string( ) ,
576+ "https://chatgpt.com/connector/oauth/abc123" . to_string( ) ,
577+ ]
578+ ) ;
579+ }
580+
504581 #[ test]
505582 fn loopback_ignores_port_per_rfc_8252 ( ) {
506583 // Registered with one port, requested with another — must match.
0 commit comments