Skip to content

Commit 8a5a4e5

Browse files
authored
Merge pull request #740 from modelcontextprotocol/fix/validate-urls
Fix/validate urls for http/https scheme only in auth flow
2 parents ade237e + a018a6d commit 8a5a4e5

File tree

5 files changed

+219
-12
lines changed

5 files changed

+219
-12
lines changed

client/src/components/AuthDebugger.tsx

Lines changed: 18 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,23 @@ 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:
175+
error instanceof Error ? error : new Error(String(error)),
176+
statusMessage: {
177+
type: "error",
178+
message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`,
179+
},
180+
});
181+
return;
182+
}
183+
166184
// Store the current auth state before redirecting
167185
sessionStorage.setItem(
168186
SESSION_KEYS.AUTH_DEBUGGER_STATE,

client/src/components/OAuthFlowProgress.tsx

Lines changed: 39 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,32 @@ 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(
250+
authState.authorizationUrl!,
251+
"_blank",
252+
"noopener noreferrer",
253+
);
254+
} catch (error) {
255+
toast({
256+
title: "Invalid URL",
257+
description:
258+
error instanceof Error
259+
? error.message
260+
: "The authorization URL is not valid",
261+
variant: "destructive",
262+
});
263+
}
264+
}}
246265
className="flex items-center text-blue-500 hover:text-blue-700"
247266
aria-label="Open authorization URL in new tab"
248267
title="Open authorization URL"
249268
>
250269
<ExternalLink className="h-4 w-4" />
251-
</a>
270+
</button>
252271
</div>
253272
<p className="text-xs text-muted-foreground mt-2">
254273
Click the link to authorize in your browser. After
@@ -354,7 +373,21 @@ export const OAuthFlowProgress = ({
354373
authState.authorizationUrl && (
355374
<Button
356375
variant="outline"
357-
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
376+
onClick={() => {
377+
try {
378+
validateRedirectUrl(authState.authorizationUrl!);
379+
window.open(authState.authorizationUrl!, "_blank");
380+
} catch (error) {
381+
toast({
382+
title: "Invalid URL",
383+
description:
384+
error instanceof Error
385+
? error.message
386+
: "The authorization URL is not valid",
387+
variant: "destructive",
388+
});
389+
}
390+
}}
358391
>
359392
Open in New Tab
360393
</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: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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(() =>
15+
validateRedirectUrl("https://example.com:8080"),
16+
).not.toThrow();
17+
});
18+
19+
it("should allow URLs with paths", () => {
20+
expect(() =>
21+
validateRedirectUrl("https://example.com/path/to/auth"),
22+
).not.toThrow();
23+
});
24+
25+
it("should allow URLs with query parameters", () => {
26+
expect(() =>
27+
validateRedirectUrl("https://example.com?param=value"),
28+
).not.toThrow();
29+
});
30+
});
31+
32+
describe("invalid URLs - XSS vectors", () => {
33+
it("should block javascript: protocol", () => {
34+
expect(() => validateRedirectUrl("javascript:alert('XSS')")).toThrow(
35+
"Authorization URL must be HTTP or HTTPS",
36+
);
37+
});
38+
39+
it("should block javascript: with encoded characters", () => {
40+
expect(() =>
41+
validateRedirectUrl("javascript:alert%28%27XSS%27%29"),
42+
).toThrow("Authorization URL must be HTTP or HTTPS");
43+
});
44+
45+
it("should block data: protocol", () => {
46+
expect(() =>
47+
validateRedirectUrl("data:text/html,<script>alert('XSS')</script>"),
48+
).toThrow("Authorization URL must be HTTP or HTTPS");
49+
});
50+
51+
it("should block vbscript: protocol", () => {
52+
expect(() => validateRedirectUrl("vbscript:msgbox")).toThrow(
53+
"Authorization URL must be HTTP or HTTPS",
54+
);
55+
});
56+
57+
it("should block file: protocol", () => {
58+
expect(() => validateRedirectUrl("file:///etc/passwd")).toThrow(
59+
"Authorization URL must be HTTP or HTTPS",
60+
);
61+
});
62+
63+
it("should block about: protocol", () => {
64+
expect(() => validateRedirectUrl("about:blank")).toThrow(
65+
"Authorization URL must be HTTP or HTTPS",
66+
);
67+
});
68+
69+
it("should block custom protocols", () => {
70+
expect(() => validateRedirectUrl("custom://example")).toThrow(
71+
"Authorization URL must be HTTP or HTTPS",
72+
);
73+
});
74+
});
75+
76+
describe("edge cases", () => {
77+
it("should handle malformed URLs", () => {
78+
expect(() => validateRedirectUrl("not a url")).toThrow(
79+
"Invalid URL: not a url",
80+
);
81+
});
82+
83+
it("should handle empty string", () => {
84+
expect(() => validateRedirectUrl("")).toThrow("Invalid URL: ");
85+
});
86+
87+
it("should handle URLs with unicode characters", () => {
88+
expect(() => validateRedirectUrl("https://例え.jp")).not.toThrow();
89+
});
90+
91+
it("should handle URLs with case variations", () => {
92+
expect(() => validateRedirectUrl("HTTPS://EXAMPLE.COM")).not.toThrow();
93+
expect(() => validateRedirectUrl("HtTpS://example.com")).not.toThrow();
94+
});
95+
96+
it("should handle protocol-relative URLs as invalid", () => {
97+
expect(() => validateRedirectUrl("//example.com")).toThrow(
98+
"Invalid URL: //example.com",
99+
);
100+
});
101+
102+
it("should handle URLs with authentication", () => {
103+
expect(() =>
104+
validateRedirectUrl("https://user:[email protected]"),
105+
).not.toThrow();
106+
});
107+
});
108+
109+
describe("security considerations", () => {
110+
it("should not be fooled by whitespace", () => {
111+
expect(() => validateRedirectUrl(" javascript:alert('XSS')")).toThrow();
112+
expect(() => validateRedirectUrl("javascript:alert('XSS') ")).toThrow();
113+
});
114+
115+
it("should handle null bytes", () => {
116+
expect(() =>
117+
validateRedirectUrl("java\x00script:alert('XSS')"),
118+
).toThrow();
119+
});
120+
121+
it("should handle tab characters", () => {
122+
expect(() => validateRedirectUrl("java\tscript:alert('XSS')")).toThrow();
123+
});
124+
125+
it("should handle newlines", () => {
126+
expect(() => validateRedirectUrl("java\nscript:alert('XSS')")).toThrow();
127+
});
128+
129+
it("should handle mixed case protocols", () => {
130+
expect(() => validateRedirectUrl("JaVaScRiPt:alert('XSS')")).toThrow(
131+
"Authorization URL must be HTTP or HTTPS",
132+
);
133+
});
134+
});
135+
});

client/src/utils/urlValidation.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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 (
16+
error instanceof Error &&
17+
error.message === "Authorization URL must be HTTP or HTTPS"
18+
) {
19+
throw error;
20+
}
21+
// If URL parsing fails, it's also invalid
22+
throw new Error(`Invalid URL: ${url}`);
23+
}
24+
}

0 commit comments

Comments
 (0)