Skip to content

Commit d6a6825

Browse files
authored
fix: properly handle external redirects (#9590)
* fix: properly handle external redirects * Handle missing loader with 400 instead of 405
1 parent 2cd8246 commit d6a6825

File tree

4 files changed

+136
-36
lines changed

4 files changed

+136
-36
lines changed

.changeset/flat-trainers-speak.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
properly handle redirects to external domains

packages/router/__tests__/router-test.ts

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5433,7 +5433,7 @@ describe("a router", () => {
54335433
it("preserves query and hash in redirects", async () => {
54345434
let t = setup({ routes: REDIRECT_ROUTES });
54355435

5436-
let nav1 = await t.fetch("/parent/child", {
5436+
let nav1 = await t.navigate("/parent/child", {
54375437
formMethod: "post",
54385438
formData: createFormData({}),
54395439
});
@@ -5459,7 +5459,7 @@ describe("a router", () => {
54595459
it("preserves query and hash in relative redirects", async () => {
54605460
let t = setup({ routes: REDIRECT_ROUTES });
54615461

5462-
let nav1 = await t.fetch("/parent/child", {
5462+
let nav1 = await t.navigate("/parent/child", {
54635463
formMethod: "post",
54645464
formData: createFormData({}),
54655465
});
@@ -5484,6 +5484,37 @@ describe("a router", () => {
54845484
errors: null,
54855485
});
54865486
});
5487+
5488+
it("processes external redirects if window is present", async () => {
5489+
let urls = [
5490+
"http://remix.run/blog",
5491+
"https://remix.run/blog",
5492+
"//remix.run/blog",
5493+
"app://whatever",
5494+
];
5495+
5496+
for (let url of urls) {
5497+
// This is gross, don't blame me, blame SO :)
5498+
// https://stackoverflow.com/a/60697570
5499+
let oldLocation = window.location;
5500+
const location = new URL(window.location.href) as unknown as Location;
5501+
location.replace = jest.fn();
5502+
delete (window as any).location;
5503+
window.location = location as unknown as Location;
5504+
5505+
let t = setup({ routes: REDIRECT_ROUTES });
5506+
5507+
let A = await t.navigate("/parent/child", {
5508+
formMethod: "post",
5509+
formData: createFormData({}),
5510+
});
5511+
5512+
await A.actions.child.redirectReturn(url);
5513+
expect(window.location.replace).toHaveBeenCalledWith(url);
5514+
5515+
window.location = oldLocation;
5516+
}
5517+
});
54875518
});
54885519

54895520
describe("scroll restoration", () => {
@@ -6822,7 +6853,7 @@ describe("a router", () => {
68226853
405,
68236854
"Method Not Allowed",
68246855
new Error(
6825-
'You made a post request to "/" but did not provide a `loader` ' +
6856+
'You made a post request to "/" but did not provide an `action` ' +
68266857
'for route "root", so there is no way to handle the request.'
68276858
),
68286859
true
@@ -10312,6 +10343,28 @@ describe("a router", () => {
1031210343
expect((response as Response).headers.get("Location")).toBe("/parent");
1031310344
});
1031410345

10346+
it("should handle external redirect Responses", async () => {
10347+
let urls = [
10348+
"http://remix.run/blog",
10349+
"https://remix.run/blog",
10350+
"//remix.run/blog",
10351+
"app://whatever",
10352+
];
10353+
10354+
for (let url of urls) {
10355+
let handler = createStaticHandler([
10356+
{
10357+
path: "/",
10358+
loader: () => redirect(url),
10359+
},
10360+
]);
10361+
let response = await handler.query(createRequest("/"));
10362+
expect(response instanceof Response).toBe(true);
10363+
expect((response as Response).status).toBe(302);
10364+
expect((response as Response).headers.get("Location")).toBe(url);
10365+
}
10366+
});
10367+
1031510368
it("should handle 404 navigations", async () => {
1031610369
let { query } = createStaticHandler(SSR_ROUTES);
1031710370
let context = await query(createRequest("/not/found"));
@@ -11311,6 +11364,29 @@ describe("a router", () => {
1131111364
expect((response as Response).headers.get("Location")).toBe("/parent");
1131211365
});
1131311366

11367+
it("should handle external redirect Responses", async () => {
11368+
let urls = [
11369+
"http://remix.run/blog",
11370+
"https://remix.run/blog",
11371+
"//remix.run/blog",
11372+
"app://whatever",
11373+
];
11374+
11375+
for (let url of urls) {
11376+
let handler = createStaticHandler([
11377+
{
11378+
id: "root",
11379+
path: "/",
11380+
loader: () => redirect(url),
11381+
},
11382+
]);
11383+
let response = await handler.queryRoute(createRequest("/"), "root");
11384+
expect(response instanceof Response).toBe(true);
11385+
expect((response as Response).status).toBe(302);
11386+
expect((response as Response).headers.get("Location")).toBe(url);
11387+
}
11388+
});
11389+
1131411390
it("should not unwrap responses returned from loaders", async () => {
1131511391
let response = json({ key: "value" });
1131611392
let { queryRoute } = createStaticHandler([
@@ -11466,13 +11542,13 @@ describe("a router", () => {
1146611542
}
1146711543
});
1146811544

11469-
it("should handle not found action/loader submissions with a 405 Response", async () => {
11545+
it("should handle missing loaders with a 400 Response", async () => {
1147011546
try {
1147111547
await queryRoute(createRequest("/"), "root");
1147211548
expect(false).toBe(true);
1147311549
} catch (data) {
1147411550
expect(isRouteErrorResponse(data)).toBe(true);
11475-
expect(data.status).toBe(405);
11551+
expect(data.status).toBe(400);
1147611552
expect(data.error).toEqual(
1147711553
new Error(
1147811554
'You made a GET request to "/" but did not provide a `loader` ' +
@@ -11481,7 +11557,9 @@ describe("a router", () => {
1148111557
);
1148211558
expect(data.internal).toBe(true);
1148311559
}
11560+
});
1148411561

11562+
it("should handle missing actions with a 405 Response", async () => {
1148511563
try {
1148611564
await queryRoute(createSubmitRequest("/"), "root");
1148711565
expect(false).toBe(true);

packages/router/router.ts

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,6 +1596,16 @@ export function createRouter(init: RouterInit): Router {
15961596
navigation.location,
15971597
"Expected a location on the redirect navigation"
15981598
);
1599+
1600+
if (
1601+
redirect.external &&
1602+
typeof window !== "undefined" &&
1603+
typeof window.location !== "undefined"
1604+
) {
1605+
window.location.replace(redirect.location);
1606+
return;
1607+
}
1608+
15991609
// There's no need to abort on redirects, since we don't detect the
16001610
// redirect until the action/loaders have settled
16011611
pendingNavigationController = null;
@@ -2185,7 +2195,7 @@ export function unstable_createStaticHandler(
21852195

21862196
// Short circuit if we have no loaders to run (queryRoute())
21872197
if (isRouteRequest && !routeMatch?.route.loader) {
2188-
throw getInternalRouterError(405, {
2198+
throw getInternalRouterError(400, {
21892199
method: request.method,
21902200
pathname: createURL(request.url).pathname,
21912201
routeId: routeMatch?.route.id,
@@ -2578,26 +2588,31 @@ async function callLoaderOrAction(
25782588
"Redirects returned/thrown from loaders/actions must have a Location header"
25792589
);
25802590

2581-
// Support relative routing in redirects
2582-
let activeMatches = matches.slice(0, matches.indexOf(match) + 1);
2583-
let routePathnames = getPathContributingMatches(activeMatches).map(
2584-
(match) => match.pathnameBase
2585-
);
2586-
let requestPath = createURL(request.url).pathname;
2587-
let resolvedLocation = resolveTo(location, routePathnames, requestPath);
2588-
invariant(
2589-
createPath(resolvedLocation),
2590-
`Unable to resolve redirect location: ${result.headers.get("Location")}`
2591-
);
2591+
// Check if this an external redirect that goes to a new origin
2592+
let external = createURL(location).origin !== createURL("/").origin;
25922593

2593-
// Prepend the basename to the redirect location if we have one
2594-
if (basename) {
2595-
let path = resolvedLocation.pathname;
2596-
resolvedLocation.pathname =
2597-
path === "/" ? basename : joinPaths([basename, path]);
2598-
}
2594+
// Support relative routing in internal redirects
2595+
if (!external) {
2596+
let activeMatches = matches.slice(0, matches.indexOf(match) + 1);
2597+
let routePathnames = getPathContributingMatches(activeMatches).map(
2598+
(match) => match.pathnameBase
2599+
);
2600+
let requestPath = createURL(request.url).pathname;
2601+
let resolvedLocation = resolveTo(location, routePathnames, requestPath);
2602+
invariant(
2603+
createPath(resolvedLocation),
2604+
`Unable to resolve redirect location: ${location}`
2605+
);
25992606

2600-
location = createPath(resolvedLocation);
2607+
// Prepend the basename to the redirect location if we have one
2608+
if (basename) {
2609+
let path = resolvedLocation.pathname;
2610+
resolvedLocation.pathname =
2611+
path === "/" ? basename : joinPaths([basename, path]);
2612+
}
2613+
2614+
location = createPath(resolvedLocation);
2615+
}
26012616

26022617
// Don't process redirects in the router during static requests requests.
26032618
// Instead, throw the Response and let the server handle it with an HTTP
@@ -2613,6 +2628,7 @@ async function callLoaderOrAction(
26132628
status,
26142629
location,
26152630
revalidate: result.headers.get("X-Remix-Revalidate") !== null,
2631+
external,
26162632
};
26172633
}
26182634

@@ -2921,7 +2937,14 @@ function getInternalRouterError(
29212937

29222938
if (status === 400) {
29232939
statusText = "Bad Request";
2924-
errorMessage = "Cannot submit binary form data using GET";
2940+
if (method && pathname && routeId) {
2941+
errorMessage =
2942+
`You made a ${method} request to "${pathname}" but ` +
2943+
`did not provide a \`loader\` for route "${routeId}", ` +
2944+
`so there is no way to handle the request.`;
2945+
} else {
2946+
errorMessage = "Cannot submit binary form data using GET";
2947+
}
29252948
} else if (status === 403) {
29262949
statusText = "Forbidden";
29272950
errorMessage = `Route "${routeId}" does not match URL "${pathname}"`;
@@ -2931,17 +2954,10 @@ function getInternalRouterError(
29312954
} else if (status === 405) {
29322955
statusText = "Method Not Allowed";
29332956
if (method && pathname && routeId) {
2934-
if (validActionMethods.has(method)) {
2935-
errorMessage =
2936-
`You made a ${method} request to "${pathname}" but ` +
2937-
`did not provide an \`action\` for route "${routeId}", ` +
2938-
`so there is no way to handle the request.`;
2939-
} else {
2940-
errorMessage =
2941-
`You made a ${method} request to "${pathname}" but ` +
2942-
`did not provide a \`loader\` for route "${routeId}", ` +
2943-
`so there is no way to handle the request.`;
2944-
}
2957+
errorMessage =
2958+
`You made a ${method} request to "${pathname}" but ` +
2959+
`did not provide an \`action\` for route "${routeId}", ` +
2960+
`so there is no way to handle the request.`;
29452961
} else {
29462962
errorMessage = `Invalid request method "${method}"`;
29472963
}

packages/router/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface RedirectResult {
4141
status: number;
4242
location: string;
4343
revalidate: boolean;
44+
external: boolean;
4445
}
4546

4647
/**

0 commit comments

Comments
 (0)