Skip to content

Commit 9affd6b

Browse files
committed
fix(oauth): DCR skips invalid redirect URIs instead of rejecting registration
Clients like Cursor send a mix of valid (custom-scheme/loopback) and invalid (non-loopback https) redirect URIs in one DCR request. Rejecting the whole registration locked them out. Now invalid URIs are filtered and registration only fails when zero valid URIs remain. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent c7dd8cc commit 9affd6b

2 files changed

Lines changed: 61 additions & 10 deletions

File tree

  • crates/mcpmux-gateway/src/oauth
  • tests/rust/tests/oauth

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

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,16 +217,24 @@ fn redirect_uri_matches(registered: &str, requested: &str) -> bool {
217217
/// 1. Loopback: http://127.0.0.1:PORT/... or http://localhost:PORT/...
218218
/// 2. Custom URL schemes: cursor://, vscode://, claude://, etc.
219219
///
220-
/// NOT allowed:
220+
/// NOT allowed (these are filtered from the request, not hard-failed):
221221
/// - https:// URLs (except for confidential clients with proper secrets)
222222
/// - http:// URLs to non-loopback addresses
223+
///
224+
/// Invalid URIs are silently skipped rather than rejecting the entire registration.
225+
/// This is necessary because some clients (notably Cursor) send a mix of valid and
226+
/// invalid redirect URIs in a single DCR request — failing the whole registration
227+
/// would lock those clients out entirely, even though they only ever use the valid
228+
/// URIs in practice. An error is only returned if zero valid URIs remain.
223229
pub fn validate_redirect_uris(uris: &[String]) -> Result<(), DcrError> {
224230
if uris.is_empty() {
225231
return Err(DcrError::invalid_redirect_uri(
226232
"At least one redirect_uri is required",
227233
));
228234
}
229235

236+
let mut valid_count = 0;
237+
230238
for uri in uris {
231239
let is_loopback = uri.starts_with("http://127.0.0.1")
232240
|| uri.starts_with("http://localhost")
@@ -237,20 +245,28 @@ pub fn validate_redirect_uris(uris: &[String]) -> Result<(), DcrError> {
237245
let is_custom_scheme = !uri.starts_with("http://") && !uri.starts_with("https://");
238246

239247
if !is_loopback && !is_custom_scheme {
248+
// Skip invalid URIs (e.g. https://www.cursor.com/agents/mcp/oauth/callback)
249+
// rather than rejecting the entire registration — clients like Cursor send a
250+
// mix of valid and invalid URIs and only ever use the valid ones in practice.
240251
warn!(
241-
"[DCR] Rejected redirect_uri: {} (must be loopback or custom scheme)",
252+
"[DCR] Skipping invalid redirect_uri: {} (must be loopback or custom scheme)",
242253
uri
243254
);
244-
return Err(DcrError::invalid_redirect_uri(
245-
"Redirect URI must be loopback (http://127.0.0.1 or http://localhost) \
246-
or a custom URL scheme (e.g., cursor://, vscode://)",
247-
));
255+
continue;
248256
}
249257

250258
debug!(
251259
"[DCR] Validated redirect_uri: {} (loopback={}, custom_scheme={})",
252260
uri, is_loopback, is_custom_scheme
253261
);
262+
valid_count += 1;
263+
}
264+
265+
if valid_count == 0 {
266+
return Err(DcrError::invalid_redirect_uri(
267+
"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://)",
269+
));
254270
}
255271

256272
Ok(())
@@ -457,11 +473,34 @@ mod tests {
457473

458474
#[test]
459475
fn test_reject_invalid_uris() {
460-
// Invalid URIs (non-loopback http)
476+
// Invalid URIs (non-loopback http) — fail when no valid URIs remain
461477
assert!(validate_redirect_uris(&["http://example.com/callback".to_string()]).is_err());
462478
assert!(validate_redirect_uris(&["https://example.com/callback".to_string()]).is_err());
463479
}
464480

481+
#[test]
482+
fn test_mixed_valid_and_invalid_uris_pass() {
483+
// Real-world case: Cursor sends a mix of valid (custom scheme + loopback) and
484+
// invalid (https) URIs. Registration must succeed as long as at least one valid
485+
// URI is present — otherwise clients that send any non-loopback HTTPS URI cannot
486+
// register at all.
487+
let uris = vec![
488+
"cursor://anysphere.cursor-mcp/oauth/callback".to_string(),
489+
"https://www.cursor.com/agents/mcp/oauth/callback".to_string(),
490+
"http://localhost:8787/callback".to_string(),
491+
];
492+
assert!(validate_redirect_uris(&uris).is_ok());
493+
}
494+
495+
#[test]
496+
fn test_all_invalid_uris_fail() {
497+
let uris = vec![
498+
"https://www.cursor.com/agents/mcp/oauth/callback".to_string(),
499+
"http://example.com/callback".to_string(),
500+
];
501+
assert!(validate_redirect_uris(&uris).is_err());
502+
}
503+
465504
#[test]
466505
fn loopback_ignores_port_per_rfc_8252() {
467506
// Registered with one port, requested with another — must match.

tests/rust/tests/oauth/dcr.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,23 @@ fn test_external_https_rejected() {
7171
}
7272

7373
#[test]
74-
fn test_mixed_valid_invalid_rejected() {
75-
// One invalid URI should fail the whole validation
74+
fn test_mixed_valid_invalid_passes() {
75+
// Invalid URIs are skipped rather than failing the whole registration, as long as
76+
// at least one valid URI remains. Clients like Cursor send a mix of valid (custom
77+
// scheme + loopback) and invalid (non-loopback https) URIs in a single DCR request.
7678
let uris = vec![
7779
"http://127.0.0.1:8080/callback".to_string(),
78-
"https://evil.com/steal".to_string(), // invalid
80+
"https://evil.com/steal".to_string(), // invalid, skipped
81+
];
82+
assert!(validate_redirect_uris(&uris).is_ok());
83+
}
84+
85+
#[test]
86+
fn test_all_invalid_rejected() {
87+
// When every URI is invalid, zero valid URIs remain and registration must fail.
88+
let uris = vec![
89+
"https://evil.com/steal".to_string(),
90+
"http://example.com/callback".to_string(),
7991
];
8092
assert!(validate_redirect_uris(&uris).is_err());
8193
}

0 commit comments

Comments
 (0)