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 2 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
56 changes: 54 additions & 2 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 @@ -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
52 changes: 34 additions & 18 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 @@ -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
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