Repository: opennextjs/opennextjs-aws
Pull request #863: Redirect requests with repeated slashes
Author: login: dnewbound0
Created: 2025-05-16T03:09:29Z | Merged: 2025-05-18T13:23:38Z

Let's say you had the following middleware function:
```ts
export async function middleware(request: NextRequest) {
  return NextResponse.redirect(new URL("/foo", request.url))
}
```
A request to `https://yourdomain.com//evil.com` would result in `request.url ===  "https://evil.com"`, leading to a malicious redirect. Here we redirect these bad requests containing repeated slashes to a sanitised path.


Diff:
diff --git a/.changeset/cuddly-dingos-walk.md b/.changeset/cuddly-dingos-walk.md
new file mode 100644
index 000000000..47213e653
--- /dev/null
+++ b/.changeset/cuddly-dingos-walk.md
@@ -0,0 +1,5 @@
+---
+"@opennextjs/aws": patch
+---
+
+feat: redirect requests with repeated slashes
diff --git a/packages/open-next/src/core/routing/matcher.ts b/packages/open-next/src/core/routing/matcher.ts
index 2d58af8db..3f4699c54 100644
--- a/packages/open-next/src/core/routing/matcher.ts
+++ b/packages/open-next/src/core/routing/matcher.ts
@@ -9,6 +9,7 @@ import type {
   RouteHas,
 } from "types/next-types";
 import type { InternalEvent, InternalResult } from "types/open-next";
+import { normalizeRepeatedSlashes } from "utils/normalize-path";
 import { emptyReadableStream, toReadableStream } from "utils/stream";
 
 import { debug } from "../../adapters/logger";
@@ -262,6 +263,29 @@ export function handleRewrites<T extends RewriteDefinition>(
   };
 }
 
+// Normalizes repeated slashes in the path e.g. hello//world -> hello/world
+// or backslashes to forward slashes. This prevents requests such as //domain
+// from invoking the middleware with `request.url === "domain"`.
+// See: https://github.com/vercel/next.js/blob/3ecf087f10fdfba4426daa02b459387bc9c3c54f/packages/next/src/server/base-server.ts#L1016-L1020
+function handleRepeatedSlashRedirect(
+  event: InternalEvent,
+): false | InternalResult {
+  // Redirect `https://example.com//foo` to `https://example.com/foo`.
+  if (event.rawPath.match(/(\\|\/\/)/)) {
+    return {
+      type: event.type,
+      statusCode: 308,
+      headers: {
+        Location: normalizeRepeatedSlashes(new URL(event.url)),
+      },
+      body: emptyReadableStream(),
+      isBase64Encoded: false,
+    };
+  }
+
+  return false;
+}
+
 function handleTrailingSlashRedirect(
   event: InternalEvent,
 ): false | InternalResult {
@@ -326,6 +350,9 @@ export function handleRedirects(
   event: InternalEvent,
   redirects: RedirectDefinition[],
 ): InternalResult | undefined {
+  const repeatedSlashRedirect = handleRepeatedSlashRedirect(event);
+  if (repeatedSlashRedirect) return repeatedSlashRedirect;
+
   const trailingSlashRedirect = handleTrailingSlashRedirect(event);
   if (trailingSlashRedirect) return trailingSlashRedirect;
 
diff --git a/packages/open-next/src/utils/normalize-path.ts b/packages/open-next/src/utils/normalize-path.ts
index f90030a73..b59a85a6e 100644
--- a/packages/open-next/src/utils/normalize-path.ts
+++ b/packages/open-next/src/utils/normalize-path.ts
@@ -4,6 +4,14 @@ export function normalizePath(path: string) {
   return path.replace(/\\/g, "/");
 }
 
+// See: https://github.com/vercel/next.js/blob/3ecf087f10fdfba4426daa02b459387bc9c3c54f/packages/next/src/shared/lib/utils.ts#L348
+export function normalizeRepeatedSlashes(url: URL) {
+  const urlNoQuery = url.host + url.pathname;
+  return `${url.protocol}//${urlNoQuery
+    .replace(/\\/g, "/")
+    .replace(/\/\/+/g, "/")}${url.search}`;
+}
+
 export function getMonorepoRelativePath(relativePath = "../.."): string {
   return path.join(
     globalThis.monorepoPackagePath
diff --git a/packages/tests-unit/tests/core/routing/matcher.test.ts b/packages/tests-unit/tests/core/routing/matcher.test.ts
index a70962fd1..10474d632 100644
--- a/packages/tests-unit/tests/core/routing/matcher.test.ts
+++ b/packages/tests-unit/tests/core/routing/matcher.test.ts
@@ -271,6 +271,17 @@ describe("getNextConfigHeaders", () => {
 });
 
 describe("handleRedirects", () => {
+  it("should redirect repeated slashes", () => {
+    const event = createEvent({
+      url: "https://on/api-route//foo",
+    });
+
+    const result = handleRedirects(event, []);
+
+    expect(result.statusCode).toEqual(308);
+    expect(result.headers.Location).toEqual("https://on/api-route/foo");
+  });
+
   it("should redirect trailing slash by default", () => {
     const event = createEvent({
       url: "https://on/api-route/",


Reviews:
user: login: conico974
body: That's basically what Next does
https://github.com/vercel/next.js/blob/3ecf087f10fdfba4426daa02b459387bc9c3c54f/packages/next/src/server/base-server.ts#L1016-L1020
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-17T11:24:04Z
user: login: dnewbound0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-17T14:01:18Z
user: login: conico974
body: Just 2 little nit, after that we should be good
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-17T15:39:48Z
user: login: conico974
body: Thanks for the PR
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-18T13:23:00Z

Review comments:
diff_hunk: @@ -1,7 +1,7 @@
 import path from "node:path";
 
 export function normalizePath(path: string) {
-  return path.replace(/\\/g, "/");
+  return path.replace(/\\/g, "/").replace(/\/\/+/g, "/");
path: packages/open-next/src/utils/normalize-path.ts
user: login: conico974
body: Don't mess with this function, we should use a separate function, just like next does https://github.com/vercel/next.js/blob/3ecf087f10fdfba4426daa02b459387bc9c3c54f/packages/next/src/shared/lib/utils.ts#L348-L360
created_at: 2025-05-17T11:14:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -262,6 +263,28 @@ export function handleRewrites<T extends RewriteDefinition>(
   };
 }
 
+function handleRepeatedSlashRedirect(
+  event: InternalEvent,
+): false | InternalResult {
+  // Redirect `https://example.com//foo` to `https://example.com/foo`.
+  const url = new URL(event.url);
+  if (url.pathname.match(/(\\|\/\/)/)) {
path: packages/open-next/src/core/routing/matcher.ts
user: login: conico974
body: Why not use `event.pathname` here ?
created_at: 2025-05-17T11:17:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -262,6 +263,28 @@ export function handleRewrites<T extends RewriteDefinition>(
   };
 }
 
+function handleRepeatedSlashRedirect(
+  event: InternalEvent,
+): false | InternalResult {
+  // Redirect `https://example.com//foo` to `https://example.com/foo`.
+  const url = new URL(event.url);
+  if (url.pathname.match(/(\\|\/\/)/)) {
path: packages/open-next/src/core/routing/matcher.ts
user: login: dnewbound0
body: do you mean `event.rawPath`? i figured they were equivalent but you're right, that does seem cleaner.
created_at: 2025-05-17T14:01:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -262,6 +263,25 @@ export function handleRewrites<T extends RewriteDefinition>(
   };
 }
 
+function handleRepeatedSlashRedirect(
path: packages/open-next/src/core/routing/matcher.ts
user: login: conico974
body: Could you add a comment explaining why we need that. With a permalink to Next https://github.com/vercel/next.js/blob/3ecf087f10fdfba4426daa02b459387bc9c3c54f/packages/next/src/server/base-server.ts#L1016-L1020
created_at: 2025-05-17T15:37:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,6 +4,13 @@ export function normalizePath(path: string) {
   return path.replace(/\\/g, "/");
 }
 
+export function normalizeRepeatedSlashes(url: URL) {
path: packages/open-next/src/utils/normalize-path.ts
user: login: conico974
body: Same thing as above, add a link to the function in Next https://github.com/vercel/next.js/blob/3ecf087f10fdfba4426daa02b459387bc9c3c54f/packages/next/src/shared/lib/utils.ts#L348-L360
created_at: 2025-05-17T15:38:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-05-16T03:09:33Z
body: ###  🦋  Changeset detected

Latest commit: d998bb86f11a3cbc8e53940b5a3c4488de49e893

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 3 packages</summary>

  | Name             | Type  |
| ---------------- | ----- |
| @opennextjs/aws  | Patch |
| app-pages-router | Patch |
| app-router       | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/dnewbound0/opennextjs-aws/new/feat/repeated-slash-redirect?filename=.changeset/flat-numbers-remain.md&value=---%0A%22%40opennextjs%2Faws%22%3A%20patch%0A%22tests-unit%22%3A%20patch%0A---%0A%0ARedirect%20requests%20with%20repeated%20slashes%0A)


author_association: NONE
user: login: conico974
created_at: 2025-05-16T06:52:55Z
body: @dnewbound0 I'm sorry but i don't understand what you're trying to solve here.
If you had this in your middleware, it would redirect to `https://yourdomain.com/foo`, that's the case for me on my test.
And modifying `handleRedirects` have absolutely no effect since middleware redirect won't go there at all
author_association: CONTRIBUTOR
user: login: dnewbound0
created_at: 2025-05-17T01:46:25Z
body: > @dnewbound0 I'm sorry but i don't understand what you're trying to solve here. If you had this in your middleware, it would redirect to `https://yourdomain.com/foo`, that's the case for me on my test. And modifying `handleRedirects` have absolutely no effect since middleware redirect won't go there at all

(i've replied to this in the OpenNext discord already but i figured i'd post my response here as well for posterity)

i can recreate this using OpenNext  v3.6.1, SST v2.48.5, and Next.js v14.2.28. making a request to `http://yourdomain.com//evil.com` gives an internal event where the raw path is `//evil.com`. as far as i understand it that means https://github.com/opennextjs/opennextjs-aws/blob/daaf38f45e6aa5596fb4dfdb912aee254fcb94e3/packages/open-next/src/core/routing/middleware.ts#L70 evaluates to `new URL("//evil.com", "http://yourdomain.com//evil.com")` so you're going to a get a middleware invocation where `request.url === "http://evil.com"`.

the PR isn't trying to change any functionality about redirecting within the middleware function, but to avoid calling the middleware entirely with that bad path. so it would go:
1. request to `http://yourdomain.com//evil.com`
2. before the middleware is invoked, redirect to `http://yourdomain.com/evil.com`
3. middleware is invoked like normal

as far as i can tell, this is how the default NextJS server handles this case?

apologies if i've missed something obvious here, this is my first PR and i'm just trying to resolve something that i noticed in our deployment :sweat_smile:
author_association: CONTRIBUTOR
user: login: pkg-pr-new[bot]
created_at: 2025-05-17T14:53:08Z
body: 
[Open in StackBlitz](https://pkg.pr.new/template/8a1ca726-8bf1-4971-906f-67257321e25e)


```
pnpm add https://pkg.pr.new/@opennextjs/aws@863
```


_commit: <a href="https://github.com/opennextjs/opennextjs-aws/runs/42426112549"><code>d998bb8</code></a>_

author_association: NONE