Skip to content

Commit 650f309

Browse files
jenn-newtonclaude
andcommitted
fix: add validation for OAuth redirect URLs
- Create shared validateRedirectUrl utility to centralize URL validation - Only allow HTTP and HTTPS protocols in OAuth authorization URLs - Fix three vulnerable redirect paths that bypassed validation: - AuthDebugger.tsx: Direct window.location.href redirect - OAuthFlowProgress.tsx: window.open() without validation - OAuthFlowProgress.tsx: Direct anchor href attribute - Add comprehensive test coverage for URL validation - Update existing auth.ts to use shared validation utility This prevents malicious MCP servers from injecting javascript: or data: protocol URLs that would execute XSS attacks when users are redirected to the authorization endpoint. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 7fc3de7 commit 650f309

File tree

5 files changed

+197
-12
lines changed

5 files changed

+197
-12
lines changed

client/src/components/AuthDebugger.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
66
import { OAuthFlowProgress } from "./OAuthFlowProgress";
77
import { OAuthStateMachine } from "../lib/oauth-state-machine";
88
import { SESSION_KEYS } from "../lib/constants";
9+
import { validateRedirectUrl } from "@/utils/urlValidation";
910

1011
export interface AuthDebuggerProps {
1112
serverUrl: string;
@@ -163,6 +164,22 @@ const AuthDebugger = ({
163164
currentState.oauthStep === "authorization_code" &&
164165
currentState.authorizationUrl
165166
) {
167+
// Validate the URL before redirecting
168+
try {
169+
validateRedirectUrl(currentState.authorizationUrl);
170+
} catch (error) {
171+
updateAuthState({
172+
...currentState,
173+
isInitiatingAuth: false,
174+
latestError: error instanceof Error ? error : new Error(String(error)),
175+
statusMessage: {
176+
type: "error",
177+
message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`,
178+
},
179+
});
180+
return;
181+
}
182+
166183
// Store the current auth state before redirecting
167184
sessionStorage.setItem(
168185
SESSION_KEYS.AUTH_DEBUGGER_STATE,

client/src/components/OAuthFlowProgress.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { Button } from "./ui/button";
44
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
55
import { useEffect, useMemo, useState } from "react";
66
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
7+
import { validateRedirectUrl } from "@/utils/urlValidation";
8+
import { useToast } from "@/lib/hooks/useToast";
79

810
interface OAuthStepProps {
911
label: string;
@@ -71,6 +73,7 @@ export const OAuthFlowProgress = ({
7173
updateAuthState,
7274
proceedToNextStep,
7375
}: OAuthFlowProgressProps) => {
76+
const { toast } = useToast();
7477
const provider = useMemo(
7578
() => new DebugInspectorOAuthClientProvider(serverUrl),
7679
[serverUrl],
@@ -239,16 +242,25 @@ export const OAuthFlowProgress = ({
239242
<p className="text-xs break-all">
240243
{authState.authorizationUrl}
241244
</p>
242-
<a
243-
href={authState.authorizationUrl}
244-
target="_blank"
245-
rel="noopener noreferrer"
245+
<button
246+
onClick={() => {
247+
try {
248+
validateRedirectUrl(authState.authorizationUrl!);
249+
window.open(authState.authorizationUrl!, "_blank");
250+
} catch (error) {
251+
toast({
252+
title: "Invalid URL",
253+
description: error instanceof Error ? error.message : "The authorization URL is not valid",
254+
variant: "destructive",
255+
});
256+
}
257+
}}
246258
className="flex items-center text-blue-500 hover:text-blue-700"
247259
aria-label="Open authorization URL in new tab"
248260
title="Open authorization URL"
249261
>
250262
<ExternalLink className="h-4 w-4" />
251-
</a>
263+
</button>
252264
</div>
253265
<p className="text-xs text-muted-foreground mt-2">
254266
Click the link to authorize in your browser. After
@@ -354,7 +366,18 @@ export const OAuthFlowProgress = ({
354366
authState.authorizationUrl && (
355367
<Button
356368
variant="outline"
357-
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
369+
onClick={() => {
370+
try {
371+
validateRedirectUrl(authState.authorizationUrl!);
372+
window.open(authState.authorizationUrl!, "_blank");
373+
} catch (error) {
374+
toast({
375+
title: "Invalid URL",
376+
description: error instanceof Error ? error.message : "The authorization URL is not valid",
377+
variant: "destructive",
378+
});
379+
}
380+
}}
358381
>
359382
Open in New Tab
360383
</Button>

client/src/lib/auth.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
1212
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
1313
import { generateOAuthState } from "@/utils/oauthUtils";
14+
import { validateRedirectUrl } from "@/utils/urlValidation";
1415

1516
/**
1617
* Discovers OAuth scopes from server metadata, with preference for resource metadata scopes
@@ -182,12 +183,8 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
182183
}
183184

184185
redirectToAuthorization(authorizationUrl: URL) {
185-
if (
186-
authorizationUrl.protocol !== "http:" &&
187-
authorizationUrl.protocol !== "https:"
188-
) {
189-
throw new Error("Authorization URL must be HTTP or HTTPS");
190-
}
186+
// Validate the URL using the shared utility
187+
validateRedirectUrl(authorizationUrl.href);
191188
window.location.href = authorizationUrl.href;
192189
}
193190

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { validateRedirectUrl } from "../urlValidation";
2+
3+
describe("validateRedirectUrl", () => {
4+
describe("valid URLs", () => {
5+
it("should allow HTTP URLs", () => {
6+
expect(() => validateRedirectUrl("http://example.com")).not.toThrow();
7+
});
8+
9+
it("should allow HTTPS URLs", () => {
10+
expect(() => validateRedirectUrl("https://example.com")).not.toThrow();
11+
});
12+
13+
it("should allow URLs with ports", () => {
14+
expect(() => validateRedirectUrl("https://example.com:8080")).not.toThrow();
15+
});
16+
17+
it("should allow URLs with paths", () => {
18+
expect(() => validateRedirectUrl("https://example.com/path/to/auth")).not.toThrow();
19+
});
20+
21+
it("should allow URLs with query parameters", () => {
22+
expect(() => validateRedirectUrl("https://example.com?param=value")).not.toThrow();
23+
});
24+
});
25+
26+
describe("invalid URLs - XSS vectors", () => {
27+
it("should block javascript: protocol", () => {
28+
expect(() => validateRedirectUrl("javascript:alert('XSS')")).toThrow(
29+
"Authorization URL must be HTTP or HTTPS"
30+
);
31+
});
32+
33+
it("should block javascript: with encoded characters", () => {
34+
expect(() => validateRedirectUrl("javascript:alert%28%27XSS%27%29")).toThrow(
35+
"Authorization URL must be HTTP or HTTPS"
36+
);
37+
});
38+
39+
it("should block data: protocol", () => {
40+
expect(() => validateRedirectUrl("data:text/html,<script>alert('XSS')</script>")).toThrow(
41+
"Authorization URL must be HTTP or HTTPS"
42+
);
43+
});
44+
45+
it("should block vbscript: protocol", () => {
46+
expect(() => validateRedirectUrl("vbscript:msgbox")).toThrow(
47+
"Authorization URL must be HTTP or HTTPS"
48+
);
49+
});
50+
51+
it("should block file: protocol", () => {
52+
expect(() => validateRedirectUrl("file:///etc/passwd")).toThrow(
53+
"Authorization URL must be HTTP or HTTPS"
54+
);
55+
});
56+
57+
it("should block about: protocol", () => {
58+
expect(() => validateRedirectUrl("about:blank")).toThrow(
59+
"Authorization URL must be HTTP or HTTPS"
60+
);
61+
});
62+
63+
it("should block custom protocols", () => {
64+
expect(() => validateRedirectUrl("custom://example")).toThrow(
65+
"Authorization URL must be HTTP or HTTPS"
66+
);
67+
});
68+
});
69+
70+
describe("edge cases", () => {
71+
it("should handle malformed URLs", () => {
72+
expect(() => validateRedirectUrl("not a url")).toThrow(
73+
"Invalid URL: not a url"
74+
);
75+
});
76+
77+
it("should handle empty string", () => {
78+
expect(() => validateRedirectUrl("")).toThrow(
79+
"Invalid URL: "
80+
);
81+
});
82+
83+
it("should handle URLs with unicode characters", () => {
84+
expect(() => validateRedirectUrl("https://例え.jp")).not.toThrow();
85+
});
86+
87+
it("should handle URLs with case variations", () => {
88+
expect(() => validateRedirectUrl("HTTPS://EXAMPLE.COM")).not.toThrow();
89+
expect(() => validateRedirectUrl("HtTpS://example.com")).not.toThrow();
90+
});
91+
92+
it("should handle protocol-relative URLs as invalid", () => {
93+
expect(() => validateRedirectUrl("//example.com")).toThrow(
94+
"Invalid URL: //example.com"
95+
);
96+
});
97+
98+
it("should handle URLs with authentication", () => {
99+
expect(() => validateRedirectUrl("https://user:[email protected]")).not.toThrow();
100+
});
101+
});
102+
103+
describe("security considerations", () => {
104+
it("should not be fooled by whitespace", () => {
105+
expect(() => validateRedirectUrl(" javascript:alert('XSS')")).toThrow();
106+
expect(() => validateRedirectUrl("javascript:alert('XSS') ")).toThrow();
107+
});
108+
109+
it("should handle null bytes", () => {
110+
expect(() => validateRedirectUrl("java\x00script:alert('XSS')")).toThrow();
111+
});
112+
113+
it("should handle tab characters", () => {
114+
expect(() => validateRedirectUrl("java\tscript:alert('XSS')")).toThrow();
115+
});
116+
117+
it("should handle newlines", () => {
118+
expect(() => validateRedirectUrl("java\nscript:alert('XSS')")).toThrow();
119+
});
120+
121+
it("should handle mixed case protocols", () => {
122+
expect(() => validateRedirectUrl("JaVaScRiPt:alert('XSS')")).toThrow(
123+
"Authorization URL must be HTTP or HTTPS"
124+
);
125+
});
126+
});
127+
});

client/src/utils/urlValidation.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Validates that a URL is safe for redirection.
3+
* Only allows HTTP and HTTPS protocols to prevent XSS attacks.
4+
*
5+
* @param url - The URL string to validate
6+
* @throws Error if the URL has an unsafe protocol
7+
*/
8+
export function validateRedirectUrl(url: string): void {
9+
try {
10+
const parsedUrl = new URL(url);
11+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
12+
throw new Error("Authorization URL must be HTTP or HTTPS");
13+
}
14+
} catch (error) {
15+
if (error instanceof Error && error.message === "Authorization URL must be HTTP or HTTPS") {
16+
throw error;
17+
}
18+
// If URL parsing fails, it's also invalid
19+
throw new Error(`Invalid URL: ${url}`);
20+
}
21+
}

0 commit comments

Comments
 (0)