Skip to content

Commit 7579bd8

Browse files
authored
Fix Pages duplicating hash in redirects (#6680)
* Fix Pages duplicating hash in redirects * Fix issue where an incoming query string got lost in a hash redirect
1 parent 648cfdd commit 7579bd8

File tree

3 files changed

+137
-37
lines changed

3 files changed

+137
-37
lines changed

.changeset/eighty-birds-kiss.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/pages-shared": patch
3+
---
4+
5+
fix: fix Pages redirects going to a hash location to be duped. This means if you have a rule like `/foo/bar /foo#bar` it will no longer result in `/foo#bar#bar` but the correct `/foo#bar`.

packages/pages-shared/__tests__/asset-server/handler.test.ts

Lines changed: 128 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -390,40 +390,6 @@ describe("asset-server handler", () => {
390390
});
391391
}
392392

393-
// test("Returns a redirect without duplicating the hash component", async () => {
394-
// const { response, spies } = await getTestResponse({
395-
// request: "https://foo.com/bar",
396-
// metadata: createMetadataObjectWithRedirects([
397-
// { from: "/bar", to: "https://foobar.com/##heading-7", status: 301 },
398-
// ]),
399-
// });
400-
401-
// expect(spies.fetchAsset).toBe(0);
402-
// expect(spies.findAssetEntryForPath).toBe(0);
403-
// expect(spies.getAssetKey).toBe(0);
404-
// expect(spies.negotiateContent).toBe(0);
405-
// expect(response.status).toBe(301);
406-
// expect(response.headers.get("Location")).toBe(
407-
// "https://foobar.com/##heading-7"
408-
// );
409-
// });
410-
411-
test("it should redirect uri-encoded paths", async () => {
412-
const { response, spies } = await getTestResponse({
413-
request: "https://foo.com/some%20page",
414-
metadata: createMetadataObjectWithRedirects([
415-
{ from: "/some%20page", to: "/home", status: 301 },
416-
]),
417-
});
418-
419-
expect(spies.fetchAsset).toBe(0);
420-
expect(spies.findAssetEntryForPath).toBe(0);
421-
expect(spies.getAssetKey).toBe(0);
422-
expect(spies.negotiateContent).toBe(0);
423-
expect(response.status).toBe(301);
424-
expect(response.headers.get("Location")).toBe("/home");
425-
});
426-
427393
// test("getResponseFromMatch - same origin paths specified as root-relative", () => {
428394
// const res = getResponseFromMatch(
429395
// {
@@ -920,6 +886,134 @@ describe("asset-server handler", () => {
920886
);
921887
});
922888
});
889+
890+
describe("redirects", () => {
891+
test("it should redirect uri-encoded paths", async () => {
892+
const { response, spies } = await getTestResponse({
893+
request: "https://foo.com/some%20page",
894+
metadata: createMetadataObjectWithRedirects([
895+
{ from: "/some%20page", to: "/home", status: 301 },
896+
]),
897+
});
898+
899+
expect(spies.fetchAsset).toBe(0);
900+
expect(spies.findAssetEntryForPath).toBe(0);
901+
expect(spies.getAssetKey).toBe(0);
902+
expect(spies.negotiateContent).toBe(0);
903+
expect(response.status).toBe(301);
904+
expect(response.headers.get("Location")).toBe("/home");
905+
});
906+
907+
test("redirects to a query string same-origin", async () => {
908+
const { response } = await getTestResponse({
909+
request: "https://foo.com/bar",
910+
metadata: createMetadataObjectWithRedirects([
911+
{ from: "/bar", to: "/?test=abc", status: 301 },
912+
]),
913+
});
914+
915+
expect(response.status).toBe(301);
916+
expect(response.headers.get("Location")).toBe("/?test=abc");
917+
});
918+
919+
test("redirects to a query string cross-origin", async () => {
920+
const { response } = await getTestResponse({
921+
request: "https://foo.com/bar",
922+
metadata: createMetadataObjectWithRedirects([
923+
{ from: "/bar", to: "https://foobar.com/?test=abc", status: 301 },
924+
]),
925+
});
926+
927+
expect(response.status).toBe(301);
928+
expect(response.headers.get("Location")).toBe(
929+
"https://foobar.com/?test=abc"
930+
);
931+
});
932+
933+
test("redirects to hash component same-origin", async () => {
934+
const { response } = await getTestResponse({
935+
request: "https://foo.com/bar",
936+
metadata: createMetadataObjectWithRedirects([
937+
{ from: "/bar", to: "https://foo.com/##heading-7", status: 301 },
938+
]),
939+
});
940+
941+
expect(response.status).toBe(301);
942+
expect(response.headers.get("Location")).toBe("/##heading-7");
943+
});
944+
945+
test("redirects to hash component cross-origin", async () => {
946+
const { response } = await getTestResponse({
947+
request: "https://foo.com/bar",
948+
metadata: createMetadataObjectWithRedirects([
949+
{ from: "/bar", to: "https://foobar.com/##heading-7", status: 301 },
950+
]),
951+
});
952+
953+
expect(response.status).toBe(301);
954+
expect(response.headers.get("Location")).toBe(
955+
"https://foobar.com/##heading-7"
956+
);
957+
});
958+
959+
test("redirects to a query string and hash same-origin", async () => {
960+
const { response } = await getTestResponse({
961+
request: "https://foo.com/bar",
962+
metadata: createMetadataObjectWithRedirects([
963+
{ from: "/bar", to: "/?test=abc#def", status: 301 },
964+
]),
965+
});
966+
967+
expect(response.status).toBe(301);
968+
expect(response.headers.get("Location")).toBe("/?test=abc#def");
969+
});
970+
971+
test("redirects to a query string and hash cross-origin", async () => {
972+
const { response } = await getTestResponse({
973+
request: "https://foo.com/bar",
974+
metadata: createMetadataObjectWithRedirects([
975+
{ from: "/bar", to: "https://foobar.com/?test=abc#def", status: 301 },
976+
]),
977+
});
978+
979+
expect(response.status).toBe(301);
980+
expect(response.headers.get("Location")).toBe(
981+
"https://foobar.com/?test=abc#def"
982+
);
983+
});
984+
985+
// Query strings must be before the hash to be considered query strings
986+
// https://www.rfc-editor.org/rfc/rfc3986#section-4.1
987+
// Behaviour in Chrome is that the .hash is "#def?test=abc" and .search is ""
988+
test("redirects to a query string and hash against rfc", async () => {
989+
const { response } = await getTestResponse({
990+
request: "https://foo.com/bar",
991+
metadata: createMetadataObjectWithRedirects([
992+
{ from: "/bar", to: "https://foobar.com/#def?test=abc", status: 301 },
993+
]),
994+
});
995+
996+
expect(response.status).toBe(301);
997+
expect(response.headers.get("Location")).toBe(
998+
"https://foobar.com/#def?test=abc"
999+
);
1000+
});
1001+
1002+
// Query string needs to be _before_ the hash
1003+
test("redirects to a hash with an incoming query cross-origin", async () => {
1004+
const { response } = await getTestResponse({
1005+
request: "https://foo.com/bar?test=abc",
1006+
metadata: createMetadataObjectWithRedirects([
1007+
{ from: "/bar", to: "https://foobar.com/#heading", status: 301 },
1008+
]),
1009+
});
1010+
1011+
expect(response.status).toBe(301);
1012+
expect(response.headers.get("Location")).toBe(
1013+
"https://foobar.com/?test=abc#heading"
1014+
);
1015+
});
1016+
});
9231017
});
9241018

9251019
interface HandlerSpies {

packages/pages-shared/asset-server/handler.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,10 @@ export async function generateHandler<
227227
? `${destination.pathname}${destination.search || search}${
228228
destination.hash
229229
}`
230-
: `${destination.href}${destination.search ? "" : search}${
231-
destination.hash
232-
}`;
230+
: `${destination.href.slice(0, destination.href.length - (destination.search.length + destination.hash.length))}${
231+
destination.search ? destination.search : search
232+
}${destination.hash}`;
233+
233234
switch (status) {
234235
case 301:
235236
return new MovedPermanentlyResponse(location, undefined, {

0 commit comments

Comments
 (0)