Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: properly handle external redirects #9590

Merged
merged 5 commits into from Nov 23, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flat-trainers-speak.md
@@ -0,0 +1,5 @@
---
"@remix-run/router": patch
---

properly handle redirects to external domains
64 changes: 59 additions & 5 deletions packages/router/__tests__/router-test.ts
Expand Up @@ -5433,7 +5433,7 @@ describe("a router", () => {
it("preserves query and hash in redirects", async () => {
let t = setup({ routes: REDIRECT_ROUTES });

let nav1 = await t.fetch("/parent/child", {
let nav1 = await t.navigate("/parent/child", {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These didn't need to be fetches, that was just as copy/paste from a prior test

formMethod: "post",
formData: createFormData({}),
});
Expand All @@ -5459,7 +5459,7 @@ describe("a router", () => {
it("preserves query and hash in relative redirects", async () => {
let t = setup({ routes: REDIRECT_ROUTES });

let nav1 = await t.fetch("/parent/child", {
let nav1 = await t.navigate("/parent/child", {
formMethod: "post",
formData: createFormData({}),
});
Expand All @@ -5484,6 +5484,30 @@ describe("a router", () => {
errors: null,
});
});

it("processes external redirects if window is present", async () => {
// This is gross, don't blame me, blame SO :)
// https://stackoverflow.com/a/60697570
let oldLocation = window.location;
const location = new URL(window.location.href) as unknown as Location;
location.replace = jest.fn();
delete (window as any).location;
window.location = location as unknown as Location;

let t = setup({ routes: REDIRECT_ROUTES });

let A = await t.navigate("/parent/child", {
formMethod: "post",
formData: createFormData({}),
});

await A.actions.child.redirectReturn("http://remix.run/blog");
expect(window.location.replace).toHaveBeenCalledWith(
"http://remix.run/blog"
);

window.location = oldLocation;
});
});

describe("scroll restoration", () => {
Expand Down Expand Up @@ -6822,7 +6846,7 @@ describe("a router", () => {
405,
"Method Not Allowed",
new Error(
'You made a post request to "/" but did not provide a `loader` ' +
'You made a post request to "/" but did not provide an `action` ' +
'for route "root", so there is no way to handle the request.'
),
true
Expand Down Expand Up @@ -10297,6 +10321,18 @@ describe("a router", () => {
expect((response as Response).headers.get("Location")).toBe("/parent");
});

it("should handle external redirect Responses", async () => {
let { query } = createStaticHandler([
{ path: "/", loader: () => redirect("http://remix.run/blog") },
]);
let response = await query(createRequest("/"));
expect(response instanceof Response).toBe(true);
expect((response as Response).status).toBe(302);
expect((response as Response).headers.get("Location")).toBe(
"http://remix.run/blog"
);
});

it("should handle 404 navigations", async () => {
let { query } = createStaticHandler(SSR_ROUTES);
let context = await query(createRequest("/not/found"));
Expand Down Expand Up @@ -11264,6 +11300,22 @@ describe("a router", () => {
expect((response as Response).headers.get("Location")).toBe("/parent");
});

it("should handle external redirect Responses", async () => {
let { queryRoute } = createStaticHandler([
{
id: "root",
path: "/",
loader: () => redirect("http://remix.run/blog"),
},
]);
let response = await queryRoute(createRequest("/"), "root");
expect(response instanceof Response).toBe(true);
expect((response as Response).status).toBe(302);
expect((response as Response).headers.get("Location")).toBe(
"http://remix.run/blog"
);
});

it("should not unwrap responses returned from loaders", async () => {
let response = json({ key: "value" });
let { queryRoute } = createStaticHandler([
Expand Down Expand Up @@ -11419,13 +11471,13 @@ describe("a router", () => {
}
});

it("should handle not found action/loader submissions with a 405 Response", async () => {
it("should handle missing loaders with a 400 Response", async () => {
try {
await queryRoute(createRequest("/"), "root");
expect(false).toBe(true);
} catch (data) {
expect(isRouteErrorResponse(data)).toBe(true);
expect(data.status).toBe(405);
expect(data.status).toBe(400);
expect(data.error).toEqual(
new Error(
'You made a GET request to "/" but did not provide a `loader` ' +
Expand All @@ -11434,7 +11486,9 @@ describe("a router", () => {
);
expect(data.internal).toBe(true);
}
});

it("should handle missing actions with a 405 Response", async () => {
try {
await queryRoute(createSubmitRequest("/"), "root");
expect(false).toBe(true);
Expand Down
78 changes: 47 additions & 31 deletions packages/router/router.ts
Expand Up @@ -1582,6 +1582,16 @@ export function createRouter(init: RouterInit): Router {
navigation.location,
"Expected a location on the redirect navigation"
);

if (
redirect.external &&
typeof window !== "undefined" &&
typeof window.location !== "undefined"
) {
window.location.replace(redirect.location);
return;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just navigate directly for external redirects instead of "starting" a new router navigation


// There's no need to abort on redirects, since we don't detect the
// redirect until the action/loaders have settled
pendingNavigationController = null;
Expand Down Expand Up @@ -2161,7 +2171,7 @@ export function unstable_createStaticHandler(

// Short circuit if we have no loaders to run (queryRoute())
if (isRouteRequest && !routeMatch?.route.loader) {
throw getInternalRouterError(405, {
throw getInternalRouterError(400, {
method: request.method,
pathname: createURL(request.url).pathname,
routeId: routeMatch?.route.id,
Expand Down Expand Up @@ -2554,26 +2564,31 @@ async function callLoaderOrAction(
"Redirects returned/thrown from loaders/actions must have a Location header"
);

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

// Prepend the basename to the redirect location if we have one
if (basename) {
let path = resolvedLocation.pathname;
resolvedLocation.pathname =
path === "/" ? basename : joinPaths([basename, path]);
}
// Support relative routing in internal redirects
if (!external) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't do any post-processing of the redirect location if it's an external location

Copy link
Contributor

@machour machour Nov 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in the middle of migrating parts of a PHP website to remix, using nginx to route some urls to remix. In my very particular case, if I redirect() from Remix to a PHP page, the origin would be the same, and this check would fail 😬

Would testing for a scheme pattern (regex, or a loose check on ://) instead negatively impact perfs?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, good call. Is that something you can do in Remix today? Or does it have this same issue?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we go with a manual check, I think we'd need to check for both protocol-less URLs (//google.com/path) as well as maybe a loose protocol check for ://. Perf shouldn't be a concern since this isn't a particularly hot code path - O(n) where n is the # of loaders/actions being run on a given navigation

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remix has the same issue today.

I'm not sure we want to support protocol-less urls, as far as I know, those are only meant to apply the current scheme in HTML.

MDN says relative or absolute, and RFC 9110 seems to require a : in absolute URIs.
But that's a long document and my nerdiness is failing me 😅

Also, if my http:// remix app, is behind a https:// nginx proxy, what scheme would //my-domain/page refer to? Doesn't seem that trivial from a server side perspective.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok yeah I figured it should since I stole the approach from there ;)

I haven't dug into the spec, but was more considering this from a browser-emulator perspective. I did some quick testing and protocol-less URLs work in both of the redirect mechanisms at play here - neither of which are truly "handled" by RR/Remix - we're just handing off a location provided by the user to a built-in browser mechanism.

Document redirects - returning a 301 with Location: //google.com works
Client-side redirects - window.location.replace('//google.com') works

So I don't think we need to implement the logic on how to handle the redirect, so much as decide when to hand off the Location provided by the user directly to the browser instead of assuming it's a path inside our app. If the browser is doing something not-technically-spec-compliant, we might be able to leave that as a browser concern?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@machour Do you mind creating a separate proposal for "same domain hard redirects"? I'm going to merge this since we need this external redirect handling in the RRR work - but I don't want to lose sight of that potential enhancement

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in #9634

let activeMatches = matches.slice(0, matches.indexOf(match) + 1);
let routePathnames = getPathContributingMatches(activeMatches).map(
(match) => match.pathnameBase
);
let requestPath = createURL(request.url).pathname;
let resolvedLocation = resolveTo(location, routePathnames, requestPath);
invariant(
createPath(resolvedLocation),
`Unable to resolve redirect location: ${location}`
);

location = createPath(resolvedLocation);
// Prepend the basename to the redirect location if we have one
if (basename) {
let path = resolvedLocation.pathname;
resolvedLocation.pathname =
path === "/" ? basename : joinPaths([basename, path]);
}

location = createPath(resolvedLocation);
}

// Don't process redirects in the router during static requests requests.
// Instead, throw the Response and let the server handle it with an HTTP
Expand All @@ -2589,6 +2604,7 @@ async function callLoaderOrAction(
status,
location,
revalidate: result.headers.get("X-Remix-Revalidate") !== null,
external,
};
}

Expand Down Expand Up @@ -2897,7 +2913,14 @@ function getInternalRouterError(

if (status === 400) {
statusText = "Bad Request";
errorMessage = "Cannot submit binary form data using GET";
if (method && pathname && routeId) {
errorMessage =
`You made a ${method} request to "${pathname}" but ` +
`did not provide a \`loader\` for route "${routeId}", ` +
`so there is no way to handle the request.`;
} else {
errorMessage = "Cannot submit binary form data using GET";
}
} else if (status === 403) {
statusText = "Forbidden";
errorMessage = `Route "${routeId}" does not match URL "${pathname}"`;
Expand All @@ -2907,17 +2930,10 @@ function getInternalRouterError(
} else if (status === 405) {
statusText = "Method Not Allowed";
if (method && pathname && routeId) {
if (validActionMethods.has(method)) {
errorMessage =
`You made a ${method} request to "${pathname}" but ` +
`did not provide an \`action\` for route "${routeId}", ` +
`so there is no way to handle the request.`;
} else {
errorMessage =
`You made a ${method} request to "${pathname}" but ` +
`did not provide a \`loader\` for route "${routeId}", ` +
`so there is no way to handle the request.`;
}
errorMessage =
`You made a ${method} request to "${pathname}" but ` +
`did not provide an \`action\` for route "${routeId}", ` +
`so there is no way to handle the request.`;
} else {
errorMessage = `Invalid request method "${method}"`;
}
Expand Down
1 change: 1 addition & 0 deletions packages/router/utils.ts
Expand Up @@ -41,6 +41,7 @@ export interface RedirectResult {
status: number;
location: string;
revalidate: boolean;
external: boolean;
}

/**
Expand Down