Skip to content

Commit 8bc2295

Browse files
rubenfiszelclaude
andauthored
fix(mcp): validate oauth dynamic client registration redirect_uris (#9197)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 20719b4 commit 8bc2295

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

backend/windmill-api/src/mcp/oauth_server.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,34 @@ pub async fn protected_resource_metadata_by_path(
285285
))
286286
}
287287

288+
/// Schemes that a browser executes as script or uses to read local content. The
289+
/// consent page navigates the user's browser to the registered redirect_uri
290+
/// (`window.location` / anchor `href`), so accepting any of these turns dynamic
291+
/// client registration into a stored-XSS / token-exfiltration primitive.
292+
const DISALLOWED_REDIRECT_URI_SCHEMES: &[&str] =
293+
&["javascript", "data", "vbscript", "blob", "file"];
294+
295+
/// Validate a dynamically-registered redirect_uri.
296+
///
297+
/// RFC 7591 dynamic client registration is intentionally unauthenticated for MCP
298+
/// interoperability, so the redirect_uri is the security boundary: it must be an
299+
/// absolute URI (RFC 6749 §3.1.2) and must not use a browser-executable scheme.
300+
fn validate_redirect_uri(uri: &str) -> Result<()> {
301+
let parsed = url::Url::parse(uri).map_err(|_| {
302+
Error::BadRequest(format!(
303+
"Invalid redirect_uri (must be an absolute URI): {uri}"
304+
))
305+
})?;
306+
// `url` normalizes the scheme to lowercase ASCII.
307+
if DISALLOWED_REDIRECT_URI_SCHEMES.contains(&parsed.scheme()) {
308+
return Err(Error::BadRequest(format!(
309+
"redirect_uri scheme '{}' is not allowed",
310+
parsed.scheme()
311+
)));
312+
}
313+
Ok(())
314+
}
315+
288316
/// POST /api/mcp/oauth/server/register - dynamic client registration
289317
pub async fn oauth_register(
290318
Extension(db): Extension<DB>,
@@ -296,6 +324,10 @@ pub async fn oauth_register(
296324
));
297325
}
298326

327+
for uri in &req.redirect_uris {
328+
validate_redirect_uri(uri)?;
329+
}
330+
299331
let client_id = format!("mcp-client-{}", rd_string(16));
300332

301333
sqlx::query!(
@@ -992,3 +1024,46 @@ pub fn gateway_unauthed_service() -> Router {
9921024
pub fn gateway_authed_service() -> Router {
9931025
Router::new().route("/approve", post(gateway_oauth_approve))
9941026
}
1027+
1028+
#[cfg(test)]
1029+
mod tests {
1030+
use super::validate_redirect_uri;
1031+
1032+
#[test]
1033+
fn accepts_legitimate_redirect_uris() {
1034+
for uri in [
1035+
"https://app.example.com/oauth/callback",
1036+
"http://localhost:9876/callback",
1037+
"http://127.0.0.1:33418/oauth/callback",
1038+
// Native/editor MCP clients use private-use URI schemes (RFC 8252).
1039+
"vscode://anthropic.claude/oauth/callback",
1040+
"cursor://anysphere.cursor/callback",
1041+
] {
1042+
assert!(
1043+
validate_redirect_uri(uri).is_ok(),
1044+
"expected {uri} to be accepted"
1045+
);
1046+
}
1047+
}
1048+
1049+
#[test]
1050+
fn rejects_browser_executable_and_relative_redirect_uris() {
1051+
for uri in [
1052+
// GHSA-q9xg-f2v2-695g: stored-XSS / token exfiltration via consent page.
1053+
"javascript:fetch('/api/w/admins/tokens/create',{method:'POST'})//",
1054+
"JavaScript:alert(document.domain)",
1055+
"data:text/html,<script>alert(1)</script>",
1056+
"vbscript:msgbox(1)",
1057+
"blob:https://example.com/uuid",
1058+
"file:///etc/passwd",
1059+
// Not an absolute URI (RFC 6749 §3.1.2).
1060+
"/relative/callback",
1061+
"not a uri",
1062+
] {
1063+
assert!(
1064+
validate_redirect_uri(uri).is_err(),
1065+
"expected {uri} to be rejected"
1066+
);
1067+
}
1068+
}
1069+
}

frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page.svelte

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@
1919
let codeChallenge = page.url.searchParams.get('code_challenge') || ''
2020
let codeChallengeMethod = page.url.searchParams.get('code_challenge_method') || ''
2121
22+
// Defense-in-depth (GHSA-q9xg-f2v2-695g): the backend validates redirect_uris at
23+
// registration, but this page navigates the browser to `redirectUri`, so reject
24+
// browser-executable schemes here too — this also neutralizes any client rows
25+
// registered before the backend fix.
26+
const DISALLOWED_REDIRECT_SCHEMES = ['javascript:', 'data:', 'vbscript:', 'blob:', 'file:']
27+
function isSafeRedirectUri(uri: string): boolean {
28+
try {
29+
return !DISALLOWED_REDIRECT_SCHEMES.includes(new URL(uri).protocol.toLowerCase())
30+
} catch {
31+
return false
32+
}
33+
}
34+
let redirectUriValid = isSafeRedirectUri(redirectUri)
35+
2236
let loading = $state(false)
2337
let success = $state(false)
2438
let successRedirectUrl = $state('')
@@ -51,6 +65,7 @@
5165
}
5266
5367
function onDeny() {
68+
if (!redirectUriValid) return
5469
// Redirect to client with error
5570
const params = new URLSearchParams({
5671
error: 'access_denied',
@@ -63,6 +78,7 @@
6378
}
6479
6580
async function onApprove() {
81+
if (!redirectUriValid) return
6682
if (!workspaceId) {
6783
sendUserToast('Please select a workspace', true)
6884
return
@@ -121,7 +137,9 @@
121137
}
122138
</script>
123139

124-
{#if !isGateway && !workspaceId}
140+
{#if !redirectUriValid}
141+
<p class="text-center text-sm text-primary mb-6"> Error: invalid or unsafe redirect_uri </p>
142+
{:else if !isGateway && !workspaceId}
125143
<p class="text-center text-sm text-primary mb-6">Error: missing workspace_id</p>
126144
{:else}
127145
<CenteredModal title={success ? 'Authorization Approved' : 'Authorization Request'}>

0 commit comments

Comments
 (0)