Skip to content

Commit df34e95

Browse files
committed
fix: filter invalid DCR redirect URIs
1 parent 57276b7 commit df34e95

1 file changed

Lines changed: 96 additions & 19 deletions

File tree

  • crates/mcpmux-gateway/src/oauth

crates/mcpmux-gateway/src/oauth/dcr.rs

Lines changed: 96 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)