-
Notifications
You must be signed in to change notification settings - Fork 469
fix: escape html in external oauth error message #841
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6811b10
e41a04e
36c23b2
c3ff8bc
4bb7fdd
7426fac
b636122
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "agents": patch | ||
| --- | ||
|
|
||
| Escape authError to prevent XSS attacks and store it in the connection state to avoid needing script tags to display error. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,6 +95,7 @@ export type MCPDiscoveryResult = { | |
| export class MCPClientConnection { | ||
| client: Client; | ||
| connectionState: MCPConnectionState = MCPConnectionState.CONNECTING; | ||
| connectionError: string | null = null; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add this string as a first class thing which can be bubbled up |
||
| lastConnectedTransport: BaseTransportType | undefined; | ||
| instructions?: string; | ||
| tools: Tool[] = []; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| import escapeHtml from "escape-html"; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this library is already in the bundle as a transient dep so thats handy |
||
| import type { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; | ||
| import type { | ||
| CallToolRequest, | ||
|
|
@@ -44,6 +45,13 @@ export type MCPServerOptions = { | |
| }; | ||
| }; | ||
|
|
||
| /** | ||
| * Result of an OAuth callback request | ||
| */ | ||
| export type MCPOAuthCallbackResult = | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this type didnt exist but it was nice to define it |
||
| | { serverId: string; authSuccess: true; authError?: undefined } | ||
| | { serverId: string; authSuccess: false; authError: string }; | ||
|
|
||
| /** | ||
| * Options for registering an MCP server | ||
| */ | ||
|
|
@@ -187,6 +195,19 @@ export class MCPClientManager { | |
| ); | ||
| } | ||
|
|
||
| private failConnection( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this standardises what happens when we have a connection so we know which one to mark as failed. extracting this logic means I dont have to repeat it for the 3 occasions this happens in |
||
| serverId: string, | ||
| error: string | ||
| ): MCPOAuthCallbackResult { | ||
| this.clearServerAuthUrl(serverId); | ||
| if (this.mcpConnections[serverId]) { | ||
| this.mcpConnections[serverId].connectionState = MCPConnectionState.FAILED; | ||
| this.mcpConnections[serverId].connectionError = error; | ||
| } | ||
| this._onServerStateChanged.fire(); | ||
| return { serverId, authSuccess: false, authError: error }; | ||
| } | ||
|
|
||
| jsonSchema: typeof import("ai").jsonSchema | undefined; | ||
|
|
||
| /** | ||
|
|
@@ -663,19 +684,19 @@ export class MCPClientManager { | |
| return servers.some((server) => server.id === serverId); | ||
| } | ||
|
|
||
| async handleCallbackRequest(req: Request) { | ||
| async handleCallbackRequest(req: Request): Promise<MCPOAuthCallbackResult> { | ||
| const url = new URL(req.url); | ||
| const code = url.searchParams.get("code"); | ||
| const state = url.searchParams.get("state"); | ||
| const error = url.searchParams.get("error"); | ||
| const errorDescription = url.searchParams.get("error_description"); | ||
|
|
||
| // Early validation - these throw because we can't identify the connection | ||
| if (!state) { | ||
| throw new Error("Unauthorized: no state provided"); | ||
| } | ||
|
|
||
| const serverId = this.extractServerIdFromState(state); | ||
|
|
||
| if (!serverId) { | ||
| throw new Error( | ||
| "No serverId found in state parameter. Expected format: {nonce}.{serverId}" | ||
|
|
@@ -684,7 +705,6 @@ export class MCPClientManager { | |
|
|
||
| const servers = this.getServersFromStorage(); | ||
| const serverExists = servers.some((server) => server.id === serverId); | ||
|
|
||
| if (!serverExists) { | ||
| throw new Error( | ||
| `No server found with id "${serverId}". Was the request matched with \`isCallbackRequest()\`?` | ||
|
|
@@ -695,89 +715,61 @@ export class MCPClientManager { | |
| throw new Error(`Could not find serverId: ${serverId}`); | ||
| } | ||
|
|
||
| // We have a valid connection - all errors from here should fail the connection | ||
| const conn = this.mcpConnections[serverId]; | ||
| if (!conn.options.transport.authProvider) { | ||
| throw new Error( | ||
| "Trying to finalize authentication for a server connection without an authProvider" | ||
| ); | ||
| } | ||
|
|
||
| const authProvider = conn.options.transport.authProvider; | ||
| authProvider.serverId = serverId; | ||
| try { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. from here we have a connection id so any errors are wrapped in this try catch and we can fail the connection gracefully. |
||
| if (!conn.options.transport.authProvider) { | ||
| throw new Error( | ||
| "Trying to finalize authentication for a server connection without an authProvider" | ||
| ); | ||
| } | ||
|
|
||
| // Two-phase state validation: check first (non-destructive), consume later | ||
| // This prevents DoS attacks where attacker consumes valid state before legitimate callback | ||
| const stateValidation = await authProvider.checkState(state); | ||
| if (!stateValidation.valid) { | ||
| this.clearServerAuthUrl(serverId); | ||
| if (this.mcpConnections[serverId]) { | ||
| this.mcpConnections[serverId].connectionState = | ||
| MCPConnectionState.FAILED; | ||
| const authProvider = conn.options.transport.authProvider; | ||
| authProvider.serverId = serverId; | ||
|
|
||
| // Two-phase state validation: check first (non-destructive), consume later | ||
| // This prevents DoS attacks where attacker consumes valid state before legitimate callback | ||
| const stateValidation = await authProvider.checkState(state); | ||
| if (!stateValidation.valid) { | ||
| throw new Error(stateValidation.error || "Invalid state"); | ||
| } | ||
| this._onServerStateChanged.fire(); | ||
| return { | ||
| serverId, | ||
| authSuccess: false, | ||
| authError: stateValidation.error || "Invalid state" | ||
| }; | ||
| } | ||
|
|
||
| if (error) { | ||
| return { | ||
| serverId, | ||
| authSuccess: false, | ||
| authError: errorDescription || error | ||
| }; | ||
| } | ||
| if (error) { | ||
| // Escape external OAuth error params to prevent XSS | ||
| throw new Error(escapeHtml(errorDescription || error)); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the big baddie xss |
||
| } | ||
|
|
||
| if (!code) { | ||
| throw new Error("Unauthorized: no code provided"); | ||
| } | ||
| if (!code) { | ||
| throw new Error("Unauthorized: no code provided"); | ||
| } | ||
|
|
||
| if ( | ||
| this.mcpConnections[serverId].connectionState === | ||
| MCPConnectionState.READY || | ||
| this.mcpConnections[serverId].connectionState === | ||
| MCPConnectionState.CONNECTED | ||
| ) { | ||
| this.clearServerAuthUrl(serverId); | ||
| return { | ||
| serverId, | ||
| authSuccess: true | ||
| }; | ||
| } | ||
| // Already authenticated - just return success | ||
| if ( | ||
| conn.connectionState === MCPConnectionState.READY || | ||
| conn.connectionState === MCPConnectionState.CONNECTED | ||
| ) { | ||
| this.clearServerAuthUrl(serverId); | ||
| return { serverId, authSuccess: true }; | ||
| } | ||
|
Comment on lines
+747
to
+754
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. happy path making sure to clear the auth url. Maybe we also need to clear the auth error also but it shouldnt be set. |
||
|
|
||
| if ( | ||
| this.mcpConnections[serverId].connectionState !== | ||
| MCPConnectionState.AUTHENTICATING | ||
| ) { | ||
| throw new Error( | ||
| `Failed to authenticate: the client is in "${this.mcpConnections[serverId].connectionState}" state, expected "authenticating"` | ||
| ); | ||
| } | ||
| if (conn.connectionState !== MCPConnectionState.AUTHENTICATING) { | ||
| throw new Error( | ||
| `Failed to authenticate: the client is in "${conn.connectionState}" state, expected "authenticating"` | ||
| ); | ||
| } | ||
|
|
||
| try { | ||
| await authProvider.consumeState(state); | ||
| await conn.completeAuthorization(code); | ||
| await authProvider.deleteCodeVerifier(); | ||
| this.clearServerAuthUrl(serverId); | ||
| conn.connectionError = null; | ||
| this._onServerStateChanged.fire(); | ||
|
Comment on lines
762
to
767
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. other happy path. |
||
|
|
||
| return { | ||
| serverId, | ||
| authSuccess: true | ||
| }; | ||
| } catch (authError) { | ||
| const errorMessage = | ||
| authError instanceof Error ? authError.message : String(authError); | ||
|
|
||
| this._onServerStateChanged.fire(); | ||
|
|
||
| return { | ||
| serverId, | ||
| authSuccess: false, | ||
| authError: errorMessage | ||
| }; | ||
| return { serverId, authSuccess: true }; | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| return this.failConnection(serverId, message); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. extracted logic |
||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove this hot garbage