Skip to content

Commit 8d6bfb0

Browse files
committed
fix(route-rules): prevent open redirect via protocol-relative url bypass
Backport of #4236 (GHSA-9phm-9p8f-hw5m). A leading `//` after the wildcard prefix (e.g. `/legacy//evil.com` matched by `/legacy/**: { redirect: "/**" }`) was emitted verbatim, yielding a protocol-relative `Location: //evil.com` that browsers resolve against the current scheme. The proxy rule had the symmetric issue. Bumps `ufo` to `^1.6.4` (collapses leading slashes inside `withoutBase`) and adds an inline collapse for the edge case where the rule itself is `/**` and `_*StripBase` is empty.
1 parent 8d06a32 commit 8d6bfb0

8 files changed

Lines changed: 44 additions & 11 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@
155155
"serve-static": "^2.2.1",
156156
"source-map": "^0.7.6",
157157
"std-env": "^4.0.0",
158-
"ufo": "^1.6.3",
158+
"ufo": "^1.6.4",
159159
"ultrahtml": "^1.6.0",
160160
"uncrypto": "^0.1.3",
161161
"unctx": "^2.5.0",

pnpm-lock.yaml

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/runtime/internal/route-rules.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export function createRouteRulesHandler(ctx: {
3939
throw createError({ statusCode: 400 });
4040
}
4141
targetPath = withoutBase(targetPath, strpBase);
42+
} else if (targetPath.startsWith("//")) {
43+
targetPath = targetPath.replace(/^\/+/, "/");
4244
}
4345
target = joinURL(target.slice(0, -3), targetPath);
4446
} else if (event.path.includes("?")) {
@@ -58,6 +60,8 @@ export function createRouteRulesHandler(ctx: {
5860
throw createError({ statusCode: 400 });
5961
}
6062
targetPath = withoutBase(targetPath, strpBase);
63+
} else if (targetPath.startsWith("//")) {
64+
targetPath = targetPath.replace(/^\/+/, "/");
6165
}
6266
target = joinURL(target.slice(0, -3), targetPath);
6367
} else if (event.path.includes("?")) {

test/fixture/nitro.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,15 @@ export default defineNitroConfig({
100100
redirect: { to: "https://nitro.build/", statusCode: 308 },
101101
},
102102
"/rules/redirect/wildcard/**": { redirect: "https://nitro.build/**" },
103+
"/rules/redirect/legacy/**": { redirect: "/**" },
103104
"/rules/nested/**": { redirect: "/base", headers: { "x-test": "test" } },
104105
"/rules/nested/override": { redirect: { to: "/other" } },
105106
"/rules/_/noncached/cached": { swr: true },
106107
"/rules/_/noncached/**": { swr: false, cache: false, isr: false },
107108
"/rules/_/cached/noncached": { cache: false, swr: false, isr: false },
108109
"/rules/_/cached/**": { swr: true },
109110
"/api/proxy/**": { proxy: "/api/echo" },
111+
"/rules/proxy/legacy/**": { proxy: "/api/wildcard/**" },
110112
"/build/**": { headers: { "x-build-header": "works" } },
111113
"**": { headers: { "x-test": "test" } },
112114
},

test/presets/netlify-legacy.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ describe("nitro:preset:netlify-legacy", async () => {
5858

5959
expect(redirects).toMatchInlineSnapshot(`
6060
"/rules/nested/override /other 302
61+
/rules/redirect/legacy/* /:splat 302
6162
/rules/redirect/wildcard/* https://nitro.build/:splat 302
6263
/rules/redirect/obj https://nitro.build/ 301
6364
/rules/nested/* /base 302

test/presets/netlify.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ describe("nitro:preset:netlify", async () => {
4747

4848
expect(redirects).toMatchInlineSnapshot(`
4949
"/rules/nested/override /other 302
50+
/rules/redirect/legacy/* /:splat 302
5051
/rules/redirect/wildcard/* https://nitro.build/:splat 302
5152
/rules/redirect/obj https://nitro.build/ 301
5253
/rules/nested/* /base 302

test/presets/vercel.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ describe("nitro:preset:vercel", async () => {
5353
"src": "/rules/redirect/wildcard/(.*)",
5454
"status": 307,
5555
},
56+
{
57+
"headers": {
58+
"Location": "/$1",
59+
},
60+
"src": "/rules/redirect/legacy/(.*)",
61+
"status": 307,
62+
},
5663
{
5764
"headers": {
5865
"Location": "/other",

test/tests.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,15 @@ export function testNitro(
284284
});
285285
expect(wildcard.status).toBe(307);
286286
expect(wildcard.headers.location).toBe("https://nitro.build/nuxt");
287+
288+
// Regression test for GHSA-9phm-9p8f-hw5m: a leading `//` after the
289+
// wildcard prefix must not be forwarded as a protocol-relative URL.
290+
const legacy = await callHandler({
291+
url: "/rules/redirect/legacy//evil.com",
292+
});
293+
expect(legacy.status).toBe(307);
294+
expect(legacy.headers.location).not.toMatch(/^\/\//);
295+
expect(legacy.headers.location).toBe("/evil.com");
287296
});
288297

289298
it("binary response", async () => {
@@ -552,6 +561,15 @@ export function testNitro(
552561
expect(data.url).toBe("/api/echo?foo=bar");
553562
});
554563

564+
it("runtime proxy collapses leading slashes after wildcard prefix", async () => {
565+
// Regression test for GHSA-9phm-9p8f-hw5m: a leading `//` after the
566+
// wildcard prefix must not be forwarded verbatim to the upstream.
567+
const { data } = await callHandler({
568+
url: "/rules/proxy/legacy//evil.com",
569+
});
570+
expect(data).toBe("evil.com");
571+
});
572+
555573
it.skipIf(ctx.preset === "bun" /* TODO */)("stream", async () => {
556574
const { data } = await callHandler({
557575
url: "/stream",

0 commit comments

Comments
 (0)