@@ -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
289317pub 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 {
9921024pub 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+ }
0 commit comments