diff --git a/package.json b/package.json index 46a585f..6d2165b 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "deploy:site": "npx wrangler deploy" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0" + "@modelcontextprotocol/sdk": "^1.11.0", + "strict-url-sanitise": "^0.0.1" }, "devDependencies": { "@axodotdev/oranda": "^0.6.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f6f618..b663c5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.11.0 version: 1.11.0 + strict-url-sanitise: + specifier: ^0.0.1 + version: 0.0.1 devDependencies: '@axodotdev/oranda': specifier: ^0.6.5 @@ -1346,6 +1349,9 @@ packages: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} + strict-url-sanitise@0.0.1: + resolution: {integrity: sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2668,6 +2674,8 @@ snapshots: stoppable@1.1.0: {} + strict-url-sanitise@0.0.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 diff --git a/src/auth/browser-provider.ts b/src/auth/browser-provider.ts index ee3da8d..f644a0e 100644 --- a/src/auth/browser-provider.ts +++ b/src/auth/browser-provider.ts @@ -1,6 +1,7 @@ // browser-provider.ts import { OAuthClientInformation, OAuthMetadata, OAuthTokens, OAuthClientMetadata } from '@modelcontextprotocol/sdk/shared/auth.js' import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { sanitizeUrl } from 'strict-url-sanitise' // Assuming StoredState is defined in ./types.js and includes fields for provider options import { StoredState } from './types.js' // Adjust path if necessary @@ -29,15 +30,16 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider { this.serverUrlHash = this.hashString(serverUrl) this.clientName = options.clientName || 'MCP Browser Client' this.clientUri = options.clientUri || (typeof window !== 'undefined' ? window.location.origin : '') - this.callbackUrl = + this.callbackUrl = sanitizeUrl( options.callbackUrl || - (typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback') + (typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback'), + ) } // --- SDK Interface Methods --- get redirectUrl(): string { - return this.callbackUrl + return sanitizeUrl(this.callbackUrl) } get clientMetadata(): OAuthClientMetadata { @@ -143,13 +145,16 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider { authorizationUrl.searchParams.set('state', state) const authUrlString = authorizationUrl.toString() + // Sanitize the authorization URL to prevent XSS attacks + const sanitizedAuthUrl = sanitizeUrl(authUrlString) + // Persist the exact auth URL in case the popup fails and manual navigation is needed - localStorage.setItem(this.getKey('last_auth_url'), authUrlString) + localStorage.setItem(this.getKey('last_auth_url'), sanitizedAuthUrl) // Attempt to open the popup const popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes' // Make configurable if needed try { - const popup = window.open(authUrlString, `mcp_auth_${this.serverUrlHash}`, popupFeatures) + const popup = window.open(sanitizedAuthUrl, `mcp_auth_${this.serverUrlHash}`, popupFeatures) if (!popup || popup.closed || typeof popup.closed === 'undefined') { console.warn( @@ -175,7 +180,8 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider { * Retrieves the last URL passed to `redirectToAuthorization`. Useful for manual fallback. */ getLastAttemptedAuthUrl(): string | null { - return localStorage.getItem(this.getKey('last_auth_url')) + const storedUrl = localStorage.getItem(this.getKey('last_auth_url')) + return storedUrl ? sanitizeUrl(storedUrl) : null } clearStorage(): number { diff --git a/src/react/useMcp.ts b/src/react/useMcp.ts index e755bb2..cd697e0 100644 --- a/src/react/useMcp.ts +++ b/src/react/useMcp.ts @@ -6,6 +6,7 @@ import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprot import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' // Added import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { auth, UnauthorizedError, OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { sanitizeUrl } from 'strict-url-sanitise' import { BrowserOAuthClientProvider } from '../auth/browser-provider.js' // Adjust path import { assert } from '../utils/assert.js' // Adjust path import type { UseMcpOptions, UseMcpResult } from './types.js' // Adjust path @@ -23,7 +24,9 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { url, clientName, clientUri, - callbackUrl = typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback', + callbackUrl = typeof window !== 'undefined' + ? sanitizeUrl(new URL('/oauth/callback', window.location.origin).toString()) + : '/oauth/callback', storageKeyPrefix = 'mcp:auth', clientConfig = {}, customHeaders = {}, @@ -189,7 +192,9 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { authProvider: authProviderRef.current, requestInit: { headers: customHeaders }, } - const targetUrl = new URL(url) + // Sanitize the URL to prevent XSS attacks from malicious server URLs + const sanitizedUrl = sanitizeUrl(url) + const targetUrl = new URL(sanitizedUrl) if (transportType === 'http') { transportInstance = new StreamableHTTPClientTransport(targetUrl, commonOptions) diff --git a/wrangler.jsonc b/wrangler.jsonc index c39cf7d..c5985f5 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -20,4 +20,4 @@ "observability": { "enabled": true } -} \ No newline at end of file +}