Skip to content

Commit 0295f90

Browse files
committed
fix(mount): normalize percent-encoded pathname in requestWithBaseURL
1 parent 9940158 commit 0295f90

4 files changed

Lines changed: 31 additions & 5 deletions

File tree

src/event.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ServerRequest, ServerRuntimeContext } from "srvx";
22
import type { H3EventContext } from "./types/context.ts";
33

44
import { EmptyObject } from "./utils/internal/obj.ts";
5+
import { decodePathname } from "./utils/internal/path.ts";
56
import { FastURL } from "srvx";
67
import type { EventHandlerRequest, TypedServerRequest } from "./types/handler.ts";
78
import type { H3Core } from "./h3.ts";
@@ -64,11 +65,8 @@ export class H3Event<
6465
const _url = (req as { _url?: URL })._url;
6566
const url = _url && _url instanceof URL ? _url : new FastURL(req.url);
6667
// Normalize percent-encoded pathname to prevent middleware bypass
67-
// Preserve %25 (encoded %) to avoid unintended double-decoding
6868
if (url.pathname.includes("%")) {
69-
url.pathname = decodeURI(
70-
url.pathname.includes("%25") ? url.pathname.replace(/%25/g, "%2525") : url.pathname,
71-
);
69+
url.pathname = decodePathname(url.pathname);
7270
}
7371
this.url = url;
7472
}

src/utils/internal/path.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ export function getPathname(path: string = "/"): string {
5252
* Resolve dot segments (`.` and `..`) in a path to prevent path traversal.
5353
* Ensures the resulting path never escapes above the root `/`.
5454
*/
55+
/**
56+
* Decode percent-encoded pathname, preserving %25 (literal `%`).
57+
*/
58+
export function decodePathname(pathname: string): string {
59+
return decodeURI(
60+
pathname.includes("%25") ? pathname.replace(/%25/g, "%2525") : pathname,
61+
);
62+
}
63+
5564
export function resolveDotSegments(path: string): string {
5665
if (!path.includes(".")) {
5766
return path;

src/utils/request.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { type ErrorDetails, HTTPError } from "../error.ts";
2+
import { decodePathname } from "./internal/path.ts";
23
import { parseQuery } from "./internal/query.ts";
34
import { validateData } from "./internal/validate.ts";
45
import { getEventContext } from "./event.ts";
@@ -33,7 +34,7 @@ export function requestWithURL(req: ServerRequest, url: string): ServerRequest {
3334
*/
3435
export function requestWithBaseURL(req: ServerRequest, base: string): ServerRequest {
3536
const url = new URL(req.url);
36-
url.pathname = url.pathname.slice(base.length) || "/";
37+
url.pathname = decodePathname(url.pathname).slice(base.length) || "/";
3738
return requestWithURL(req, url.href);
3839
}
3940

test/mount.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@ describeMatrix("mount", (t, { it, expect, describe }) => {
1010
expect(await t.fetch("/test/123").then((r) => r.text())).toBe("/123");
1111
});
1212

13+
it("normalizes percent-encoded base path", async () => {
14+
t.app.mount("/api", async (req) => {
15+
const url = new URL(req.url);
16+
if (url.pathname.startsWith("/admin")) {
17+
return new Response("Forbidden", { status: 403 });
18+
}
19+
return new Response(`OK: ${url.pathname}`);
20+
});
21+
22+
// Normal request should be blocked
23+
const res1 = await t.fetch("/api/admin");
24+
expect(res1.status).toBe(403);
25+
26+
// Percent-encoded base path should still be blocked
27+
const res2 = await t.fetch("/%61pi/admin");
28+
expect(res2.status).toBe(403);
29+
});
30+
1331
it("works with compat object", async () => {
1432
t.app.mount("/test", {
1533
fetch: (req: Request) => new Response(new URL(req.url).pathname),

0 commit comments

Comments
 (0)