Skip to content

Commit ce596e8

Browse files
authored
Validate RSC redirect protocols (#15177)
1 parent 1cebd2a commit ce596e8

7 files changed

Lines changed: 114 additions & 19 deletions

File tree

integration/rsc/rsc-nojs-test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,16 @@ implementations.forEach((implementation) => {
115115
throw redirect("https://example.com/");
116116
}
117117
118+
if (id === "unsupported-protocol") {
119+
throw redirect("about:blank");
120+
}
121+
118122
return (
119123
<>
120124
<h1>{id || "home"}</h1>
121125
<Link to="/render-redirect/redirect">Redirect</Link>
122126
<Link to="/render-redirect/external">External</Link>
127+
<Link to="/render-redirect/unsupported-protocol">Unsupported</Link>
123128
</>
124129
)
125130
}
@@ -147,11 +152,16 @@ implementations.forEach((implementation) => {
147152
throw redirect("https://example.com/");
148153
}
149154
155+
if (id === "unsupported-protocol") {
156+
throw redirect("about:blank");
157+
}
158+
150159
return (
151160
<>
152161
<h1>{id || "home"}</h1>
153162
<Link to="/render-redirect/lazy/redirect">Redirect</Link>
154163
<Link to="/render-redirect/external">External</Link>
164+
<Link to="/render-redirect/lazy/unsupported-protocol">Unsupported</Link>
155165
</>
156166
);
157167
}
@@ -234,6 +244,16 @@ implementations.forEach((implementation) => {
234244
await expect(page.getByText("Example Domain")).toBeAttached();
235245
});
236246

247+
test("Handles unsupported protocol redirect Responses from render", async ({
248+
page,
249+
}) => {
250+
let response = await page.request.get(
251+
`http://localhost:${port}/render-redirect/unsupported-protocol`,
252+
{ maxRedirects: 0 },
253+
);
254+
expect(response.headers()["location"]).not.toBe("about:blank");
255+
});
256+
237257
test("Suppport throwing redirect Response from suspended render", async ({
238258
page,
239259
}) => {
@@ -256,5 +276,16 @@ implementations.forEach((implementation) => {
256276
await page.waitForURL(`https://example.com/`);
257277
await expect(page.getByText("Example Domain")).toBeAttached();
258278
});
279+
280+
test("Handles unsupported protocol redirect Responses from suspended render", async ({
281+
page,
282+
}) => {
283+
let response = await page.request.get(
284+
`http://localhost:${port}/render-redirect/lazy/unsupported-protocol`,
285+
);
286+
expect(await response.text()).not.toContain(
287+
'<meta http-equiv="refresh" content="0;url=about:',
288+
);
289+
});
259290
});
260291
});

integration/rsc/rsc-test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,11 +1490,16 @@ implementations.forEach((implementation) => {
14901490
throw redirect("https://example.com/")
14911491
}
14921492
1493+
if (id === "unsupported-protocol") {
1494+
throw redirect("about:blank")
1495+
}
1496+
14931497
return (
14941498
<>
14951499
<h1>{id || "home"}</h1>
14961500
<Link to="/render-redirect/redirect">Redirect</Link>
14971501
<Link to="/render-redirect/external">External</Link>
1502+
<Link to="/render-redirect/unsupported-protocol">Unsupported</Link>
14981503
</>
14991504
)
15001505
}
@@ -1522,11 +1527,16 @@ implementations.forEach((implementation) => {
15221527
throw redirect("https://example.com/")
15231528
}
15241529
1530+
if (id === "unsupported-protocol") {
1531+
throw redirect("about:blank")
1532+
}
1533+
15251534
return (
15261535
<>
15271536
<h1>{id || "home"}</h1>
15281537
<Link to="/render-redirect/lazy/redirect">Redirect</Link>
15291538
<Link to="/render-redirect/external">External</Link>
1539+
<Link to="/render-redirect/lazy/unsupported-protocol">Unsupported</Link>
15301540
</>
15311541
);
15321542
}
@@ -1867,6 +1877,18 @@ implementations.forEach((implementation) => {
18671877
await expect(page.getByText("Example Domain")).toBeAttached();
18681878
});
18691879

1880+
test("Handles unsupported protocol redirect Responses from render", async ({
1881+
page,
1882+
}) => {
1883+
await page.goto(`http://localhost:${port}/render-redirect`);
1884+
await expect(page.getByText("home")).toBeAttached();
1885+
await page.getByText("Unsupported").click();
1886+
await page.waitForTimeout(500);
1887+
await expect(page).toHaveURL(
1888+
`http://localhost:${port}/render-redirect/unsupported-protocol`,
1889+
);
1890+
});
1891+
18701892
test("Suppport throwing redirect Response from suspended render", async ({
18711893
page,
18721894
}) => {
@@ -1894,6 +1916,18 @@ implementations.forEach((implementation) => {
18941916
await expect(page.getByText("Example Domain")).toBeAttached();
18951917
});
18961918

1919+
test("Handles unsupported protocol redirect Responses from suspended render", async ({
1920+
page,
1921+
}) => {
1922+
await page.goto(`http://localhost:${port}/render-redirect/lazy`);
1923+
await expect(page.getByText("home")).toBeAttached();
1924+
await page.getByText("Unsupported").click();
1925+
await page.waitForTimeout(500);
1926+
await expect(page).toHaveURL(
1927+
`http://localhost:${port}/render-redirect/lazy/unsupported-protocol`,
1928+
);
1929+
});
1930+
18971931
test("Support throwing Responses", async ({ page }) => {
18981932
await page.goto(
18991933
`http://localhost:${port}/render-route-error-response`,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Validate protocols in RSC render redirects

packages/react-router/lib/hooks.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import type {
2626
RevalidationState,
2727
NavigationStates,
2828
} from "./router/router";
29-
import { IDLE_BLOCKER } from "./router/router";
29+
import { hasInvalidProtocol, IDLE_BLOCKER } from "./router/router";
3030
import type {
3131
DataRouteMatch,
3232
ParamParseKey,
@@ -1118,6 +1118,7 @@ export class RenderErrorBoundary extends React.Component<
11181118
}
11191119

11201120
const errorRedirectHandledMap = new WeakMap<any, Promise<void>>();
1121+
11211122
function RSCErrorHandler({
11221123
children,
11231124
error,
@@ -1139,10 +1140,14 @@ function RSCErrorHandler({
11391140
if (existingRedirect) throw existingRedirect;
11401141

11411142
let parsed = parseToInfo(redirect.location, basename);
1143+
let target = parsed.absoluteURL || parsed.to;
1144+
if (hasInvalidProtocol(target)) {
1145+
throw new Error("Invalid redirect location");
1146+
}
11421147

11431148
if (isBrowser && !errorRedirectHandledMap.get(error)) {
11441149
if (parsed.isExternal || redirect.reloadDocument) {
1145-
window.location.href = parsed.absoluteURL || parsed.to;
1150+
window.location.href = target;
11461151
} else {
11471152
const redirectPromise: Promise<void> = Promise.resolve().then(() =>
11481153
window.__reactRouterDataRouter!.navigate(parsed.to, {
@@ -1154,12 +1159,7 @@ function RSCErrorHandler({
11541159
}
11551160
}
11561161

1157-
return (
1158-
<meta
1159-
httpEquiv="refresh"
1160-
content={`0;url=${parsed.absoluteURL || parsed.to}`}
1161-
/>
1162-
);
1162+
return <meta httpEquiv="refresh" content={`0;url=${target}`} />;
11631163
}
11641164
}
11651165
return children;

packages/react-router/lib/router/router.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6835,6 +6835,14 @@ export const invalidProtocols = [
68356835
"javascript:",
68366836
];
68376837

6838+
export function hasInvalidProtocol(location: string): boolean {
6839+
try {
6840+
return invalidProtocols.includes(new URL(location).protocol);
6841+
} catch {
6842+
return false;
6843+
}
6844+
}
6845+
68386846
function normalizeRedirectLocation(
68396847
location: string,
68406848
currentUrl: URL,
@@ -6849,7 +6857,7 @@ function normalizeRedirectLocation(
68496857
normalizeProtocolRelativeUrl(normalizedLocation, currentUrl.protocol),
68506858
)
68516859
: new URL(normalizedLocation);
6852-
if (invalidProtocols.includes(url.protocol)) {
6860+
if (hasInvalidProtocol(url.toString())) {
68536861
throw new Error("Invalid redirect location");
68546862
}
68556863
let isSameBasename = stripBasename(url.pathname, basename) != null;
@@ -6860,7 +6868,7 @@ function normalizeRedirectLocation(
68606868

68616869
try {
68626870
let url = historyInstance.createURL(location);
6863-
if (invalidProtocols.includes(url.protocol)) {
6871+
if (hasInvalidProtocol(url.toString())) {
68646872
throw new Error("Invalid redirect location");
68656873
}
68666874
} catch (

packages/react-router/lib/rsc/browser.tsx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { createBrowserHistory, invariant } from "../router/history";
99
import type { Router as DataRouter, RouterInit } from "../router/router";
1010
import {
1111
createRouter,
12-
invalidProtocols,
12+
hasInvalidProtocol,
1313
isMutationMethod,
1414
} from "../router/router";
1515
import type {
@@ -1109,14 +1109,6 @@ function isExternalLocation(location: string) {
11091109
return newLocation.origin !== window.location.origin;
11101110
}
11111111

1112-
function hasInvalidProtocol(location: string): boolean {
1113-
try {
1114-
return invalidProtocols.includes(new URL(location).protocol);
1115-
} catch {
1116-
return false;
1117-
}
1118-
}
1119-
11201112
function normalizeRedirectLocation(location: string): string {
11211113
if (PROTOCOL_RELATIVE_URL_REGEX.test(location)) {
11221114
let path = resolvePath(location);

packages/react-router/lib/rsc/server.ssr.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { shouldHydrateRouteLoader } from "../dom/ssr/routes";
1010
import type { RSCPayload } from "./server.rsc";
1111
import { createRSCRouteModules } from "./route-modules";
1212
import { isRouteErrorResponse, type DataRouteObject } from "../router/utils";
13+
import { hasInvalidProtocol } from "../router/router";
1314
import {
1415
decodeRedirectErrorDigest,
1516
decodeRouteErrorResponseDigest,
@@ -202,6 +203,10 @@ export async function routeRSCServerRequest({
202203
serverResponse.status === SINGLE_FETCH_REDIRECT_STATUS &&
203204
payload.type === "redirect"
204205
) {
206+
if (hasInvalidProtocol(payload.location)) {
207+
throw new Error("Invalid redirect location");
208+
}
209+
205210
const headers = new Headers(serverResponse.headers);
206211
headers.delete("Content-Encoding");
207212
headers.delete("Content-Length");
@@ -255,6 +260,10 @@ export async function routeRSCServerRequest({
255260
headers.set("Content-Type", "text/html; charset=utf-8");
256261

257262
if (renderRedirect) {
263+
if (hasInvalidProtocol(renderRedirect.location)) {
264+
throw new Error("Invalid redirect location");
265+
}
266+
258267
headers.set("Location", renderRedirect.location);
259268
return new Response(html, {
260269
status: renderRedirect.status,
@@ -265,6 +274,10 @@ export async function routeRSCServerRequest({
265274
const redirectTransform = new TransformStream({
266275
flush(controller) {
267276
if (renderRedirect) {
277+
if (hasInvalidProtocol(renderRedirect.location)) {
278+
return;
279+
}
280+
268281
controller.enqueue(
269282
new TextEncoder().encode(
270283
`<meta http-equiv="refresh" content="0;url=${escapeHtml(renderRedirect.location)}"/>`,
@@ -300,6 +313,10 @@ export async function routeRSCServerRequest({
300313
}
301314

302315
if (renderRedirect) {
316+
if (hasInvalidProtocol(renderRedirect.location)) {
317+
throw new Error("Invalid redirect location");
318+
}
319+
303320
return new Response(`Redirect: ${renderRedirect.location}`, {
304321
status: renderRedirect.status,
305322
headers: {
@@ -388,6 +405,10 @@ export async function routeRSCServerRequest({
388405
headers.set("Content-Type", "text/html; charset=utf-8");
389406

390407
if (retryRedirect) {
408+
if (hasInvalidProtocol(retryRedirect.location)) {
409+
throw new Error("Invalid redirect location");
410+
}
411+
391412
headers.set("Location", retryRedirect.location);
392413
return new Response(html, {
393414
status: retryRedirect.status,
@@ -398,6 +419,10 @@ export async function routeRSCServerRequest({
398419
const retryRedirectTransform = new TransformStream({
399420
flush(controller) {
400421
if (retryRedirect) {
422+
if (hasInvalidProtocol(retryRedirect.location)) {
423+
return;
424+
}
425+
401426
controller.enqueue(
402427
new TextEncoder().encode(
403428
`<meta http-equiv="refresh" content="0;url=${escapeHtml(retryRedirect.location)}"/>`,
@@ -495,6 +520,10 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) {
495520
const payload = useSafe(decoded);
496521

497522
if (payload.type === "redirect") {
523+
if (hasInvalidProtocol(payload.location)) {
524+
throw new Error("Invalid redirect location");
525+
}
526+
498527
throw new Response(null, {
499528
status: payload.status,
500529
headers: {

0 commit comments

Comments
 (0)