Skip to content

Commit 9b0c021

Browse files
authored
fix: Normalize the Location header in redirects (#941)
* add normalizeLocationHeader add comment in middleware check status code fix e2e review add unit test for normalizeLocationHeader refactor to match Next behavior update unit test refactor comment refactor changeset * short circuit
1 parent 521346b commit 9b0c021

File tree

6 files changed

+131
-12
lines changed

6 files changed

+131
-12
lines changed

.changeset/fluffy-dingos-tap.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@opennextjs/aws": patch
3+
---
4+
5+
fix: Normalize the Location header in redirects
6+
7+
Normalizes the Location header to either be a relative path or a full URL.
8+
If the Location header is relative to the host, it will return a relative path.
9+
If it is an absolute URL, it will return the full URL.
10+
We want to match the behavior of `next start`.
11+
Query parameters in redirects from Next Config are encoded, but from the middleware they are not touched.

packages/open-next/src/core/routing/middleware.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
convertBodyToReadableStream,
1616
getMiddlewareMatch,
1717
isExternal,
18+
normalizeLocationHeader,
1819
} from "./util.js";
1920

2021
const middlewareManifest = MiddlewareManifest;
@@ -25,6 +26,8 @@ const middleMatch = getMiddlewareMatch(
2526
functionsConfigManifest,
2627
);
2728

29+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
30+
2831
type MiddlewareEvent = InternalEvent & {
2932
responseHeaders?: Record<string, string | string[]>;
3033
isExternalRewrite?: boolean;
@@ -133,6 +136,11 @@ export async function handleMiddleware(
133136
resHeaders[key] = resHeaders[key]
134137
? [...resHeaders[key], value]
135138
: [value];
139+
} else if (
140+
REDIRECTS.has(statusCode) &&
141+
key.toLowerCase() === "location"
142+
) {
143+
resHeaders[key] = normalizeLocationHeader(value, internalEvent.url);
136144
} else {
137145
resHeaders[key] = value;
138146
}

packages/open-next/src/core/routing/util.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import crypto from "node:crypto";
22
import type { OutgoingHttpHeaders } from "node:http";
3+
import { parse as parseQs, stringify as stringifyQs } from "node:querystring";
34
import { Readable } from "node:stream";
45

56
import { BuildId, HtmlPages, NextConfig } from "config/index.js";
@@ -438,3 +439,42 @@ export async function invalidateCDNOnRequest(
438439
]);
439440
}
440441
}
442+
443+
/**
444+
* Normalizes the Location header to either be a relative path or a full URL.
445+
* If the Location header is relative to the origin, it will return a relative path.
446+
* If it is an absolute URL, it will return the full URL.
447+
* Redirects from Next config query parameters are encoded using `stringifyQs`
448+
* Redirects from the middleware the query parameters are not encoded.
449+
*
450+
* @param location The Location header value
451+
* @param baseUrl The base URL to use for relative paths (i.e the original request URL)
452+
* @param encodeQuery Optional flag to indicate if query parameters should be encoded in the Location header
453+
* @returns An absolute or relative Location header value
454+
*/
455+
export function normalizeLocationHeader(
456+
location: string,
457+
baseUrl: string,
458+
encodeQuery = false,
459+
): string {
460+
if (!URL.canParse(location)) {
461+
// If the location is not a valid URL, return it as-is
462+
return location;
463+
}
464+
465+
const locationURL = new URL(location);
466+
const origin = new URL(baseUrl).origin;
467+
468+
let search = locationURL.search;
469+
// If encodeQuery is true, we need to encode the query parameters
470+
// We could have used URLSearchParams, but that doesn't match what Next does.
471+
if (encodeQuery && search) {
472+
search = `?${stringifyQs(parseQs(search.slice(1)))}`;
473+
}
474+
const href = `${locationURL.origin}${locationURL.pathname}${search}${locationURL.hash}`;
475+
// The URL is relative if the origin is the same as the base URL's origin
476+
if (locationURL.origin === origin) {
477+
return href.slice(origin.length);
478+
}
479+
return href;
480+
}

packages/open-next/src/core/routingHandler.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
dynamicRouteMatcher,
2929
staticRouteMatcher,
3030
} from "./routing/routeMatcher";
31-
import { constructNextUrl } from "./routing/util";
31+
import { constructNextUrl, normalizeLocationHeader } from "./routing/util";
3232

3333
export const MIDDLEWARE_HEADER_PREFIX = "x-middleware-response-";
3434
export const MIDDLEWARE_HEADER_PREFIX_LEN = MIDDLEWARE_HEADER_PREFIX.length;
@@ -110,14 +110,14 @@ export default async function routingHandler(
110110
const redirect = handleRedirects(eventOrResult, RoutesManifest.redirects);
111111
if (redirect) {
112112
// We need to encode the value in the Location header to make sure it is valid according to RFC
113-
// https://stackoverflow.com/a/7654605/16587222
114-
redirect.headers.Location = new URL(
113+
redirect.headers.Location = normalizeLocationHeader(
115114
redirect.headers.Location as string,
116-
).href;
115+
event.url,
116+
true,
117+
);
117118
debug("redirect", redirect);
118119
return redirect;
119120
}
120-
121121
const middlewareEventOrResult = await handleMiddleware(
122122
eventOrResult,
123123
// We need to pass the initial search without any decoding

packages/tests-e2e/tests/appRouter/config.redirect.test.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ test.describe("Next Config Redirect", () => {
7474
});
7575
test("Should properly encode the Location header for redirects with query params", async ({
7676
page,
77-
baseURL,
7877
}) => {
7978
await page.goto("/config-redirect");
8079
const responsePromise = page.waitForResponse((response) => {
@@ -86,17 +85,14 @@ test.describe("Next Config Redirect", () => {
8685

8786
const locationHeader = res.headers().location;
8887
expect(locationHeader).toBe(
89-
`${baseURL}/config-redirect/dest?q=%C3%A4%C3%B6%C3%A5%E2%82%AC`,
88+
"/config-redirect/dest?q=%C3%A4%C3%B6%C3%A5%E2%82%AC",
9089
);
9190
expect(res.status()).toBe(307);
9291

9392
const searchParams = page.getByTestId("searchParams");
9493
await expect(searchParams).toHaveText("q: äöå€");
9594
});
96-
test("Should respect already encoded query params", async ({
97-
page,
98-
baseURL,
99-
}) => {
95+
test("Should respect already encoded query params", async ({ page }) => {
10096
await page.goto("/config-redirect");
10197
const responsePromise = page.waitForResponse((response) => {
10298
return response.status() === 307;
@@ -107,7 +103,7 @@ test.describe("Next Config Redirect", () => {
107103

108104
const locationHeader = res.headers().location;
109105
expect(locationHeader).toBe(
110-
`${baseURL}/config-redirect/dest?q=%C3%A4%C3%B6%C3%A5%E2%82%AC`,
106+
"/config-redirect/dest?q=%C3%A4%C3%B6%C3%A5%E2%82%AC",
111107
);
112108
expect(res.status()).toBe(307);
113109

packages/tests-unit/tests/core/routing/util.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
getUrlParts,
1818
invalidateCDNOnRequest,
1919
isExternal,
20+
normalizeLocationHeader,
2021
revalidateIfRequired,
2122
unescapeRegex,
2223
} from "@opennextjs/aws/core/routing/util.js";
@@ -876,3 +877,66 @@ describe("constructNextUrl", () => {
876877
expect(result).toBe("http://localhost/base/path");
877878
});
878879
});
880+
881+
describe("normalizeLocationHeader", () => {
882+
it("should normalize relative location header", () => {
883+
const result = normalizeLocationHeader(
884+
"http://localhost:3000/path",
885+
"http://localhost:3000",
886+
);
887+
expect(result).toBe("/path");
888+
});
889+
890+
it("should not change absolute location header", () => {
891+
const result = normalizeLocationHeader(
892+
"https://opennext.js.org/aws",
893+
"http://localhost:3000",
894+
);
895+
expect(result).toBe("https://opennext.js.org/aws");
896+
});
897+
898+
it("should normalize relative location with query parameters", () => {
899+
const result = normalizeLocationHeader(
900+
"http://localhost:3000/path?query=1",
901+
"http://localhost:3000",
902+
);
903+
expect(result).toBe("/path?query=1");
904+
});
905+
906+
it("should normalize and encode special characters in location header", () => {
907+
const result = normalizeLocationHeader(
908+
"http://localhost:3000/about?query=æøå!&",
909+
"http://localhost:3000",
910+
true,
911+
);
912+
expect(result).toBe("/about?query=%C3%A6%C3%B8%C3%A5!");
913+
});
914+
915+
it("should normalize and respect already encoded characters in location header", () => {
916+
const result = normalizeLocationHeader(
917+
"http://localhost:3000/path?query=test%2F%2F",
918+
"http://localhost:3000",
919+
true,
920+
);
921+
expect(result).toBe("/path?query=test%2F%2F");
922+
});
923+
924+
it("should preserve multiple query parameters in location header", () => {
925+
const result = normalizeLocationHeader(
926+
"http://localhost:3000/path?query1=value1&query2=value2&query1=value3&query2=value4&random=randomvalue",
927+
"http://localhost:3000",
928+
true,
929+
);
930+
expect(result).toBe(
931+
"/path?query1=value1&query1=value3&query2=value2&query2=value4&random=randomvalue",
932+
);
933+
});
934+
935+
it("should return absolute URL correctly when the base URL is different", () => {
936+
const result = normalizeLocationHeader(
937+
"https://example.com/path?query=1",
938+
"http://localhost:3000",
939+
);
940+
expect(result).toBe("https://example.com/path?query=1");
941+
});
942+
});

0 commit comments

Comments
 (0)