Skip to content

Commit a1cf066

Browse files
pi0claude
andcommitted
fix(event): clone URL for pathname normalization instead of mutating shared _url
The runtime-provided parsed URL (req._url) is shared with req.url and, on node, srvx syncs pathname mutations back into the raw request. The in-place percent-decode therefore destroyed the original wire encoding on node while web runtimes kept it, leaving no way to read the undecoded URL. Build a fresh FastURL when decoding changes the pathname so event.url is normalized on every runtime while req.url and the raw node req.url stay pristine. The clone (one URL parse) is only paid on pathnames whose decode is not an identity. closes #1432 Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent 8410ec9 commit a1cf066

4 files changed

Lines changed: 36 additions & 17 deletions

File tree

src/event.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,16 @@ export class H3Event<
6565
this.app = app;
6666
// Parsed URL can be provided by srvx (node) and other runtimes
6767
const _url = (req as { _url?: URL })._url;
68-
const url = _url && _url instanceof URL ? _url : new FastURL(req.url);
68+
let url = _url && _url instanceof URL ? _url : new FastURL(req.url);
6969
// Normalize percent-encoded pathname to prevent middleware bypass
7070
if (url.pathname.includes("%")) {
7171
try {
72-
url.pathname = decodePathname(url.pathname);
72+
const pathname = decodePathname(url.pathname);
73+
if (pathname !== url.pathname) {
74+
// Clone instead of mutating: the parsed URL is shared with the
75+
// runtime, and req.url must keep the original wire encoding (#1432)
76+
url = new FastURL(`${url.protocol}//${url.host}${pathname}${url.search}`);
77+
}
7378
} catch {
7479
// Malformed percent-encoding (e.g. `/foo%`, `/%ZZ`): flag for a 400
7580
// response and keep the raw pathname so route matching and middleware

test/bench/bundle.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ describe("benchmark", () => {
3434
if (process.env.DEBUG) {
3535
console.log(`Bundle size (H3Core): ${bundle.bytes} (gzip: ${bundle.gzipSize})`);
3636
}
37-
expect(bundle.bytes).toBeLessThanOrEqual(6600); // <6.6kb
38-
expect(bundle.gzipSize).toBeLessThanOrEqual(2650); // <2.65kb
37+
expect(bundle.bytes).toBeLessThanOrEqual(6650); // <6.65kb
38+
expect(bundle.gzipSize).toBeLessThanOrEqual(2680); // <2.68kb
3939
});
4040

4141
it("bundle size (defineHandler)", async () => {
@@ -51,7 +51,7 @@ describe("benchmark", () => {
5151
console.log(`Bundle size (defineHandler): ${bundle.bytes} (gzip: ${bundle.gzipSize})`);
5252
}
5353
expect(bundle.bytes).toBeLessThanOrEqual(6200); // <6.2kb
54-
expect(bundle.gzipSize).toBeLessThanOrEqual(2500); // <2.5kb
54+
expect(bundle.gzipSize).toBeLessThanOrEqual(2520); // <2.52kb
5555
});
5656
});
5757

test/event.test.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describeMatrix("event", (t, { it, expect }) => {
9393
expect(await result.text()).toBe("200");
9494
});
9595

96-
it("normalizes percent-encoded pathname and syncs the raw node url", async () => {
96+
it("normalizes event.url without touching req.url on any runtime", async () => {
9797
t.app.all("/*", (event) => ({
9898
pathname: event.url.pathname,
9999
reqUrl: event.req.url,
@@ -102,15 +102,11 @@ describeMatrix("event", (t, { it, expect }) => {
102102
const res = await t.fetch("/h%65llo?q=%41");
103103
const body = await res.json();
104104
expect(body.pathname).toBe("/hello");
105+
// req.url keeps the original wire encoding on every runtime (#1432)
106+
expect(new URL(body.reqUrl).pathname).toBe("/h%65llo");
105107
if (t.target === "node") {
106-
// srvx derives req.url from the shared _url, so the normalization
107-
// shows through, and NodeRequestURL writes pathname mutations back
108-
// to the raw Node request; the search string must stay untouched
109-
expect(new URL(body.reqUrl).pathname).toBe("/hello");
110-
expect(body.rawNodeUrl).toBe("/hello?q=%41");
108+
expect(body.rawNodeUrl).toBe("/h%65llo?q=%41");
111109
} else {
112-
// web Request.url is an immutable string and keeps the wire encoding
113-
expect(new URL(body.reqUrl).pathname).toBe("/h%65llo");
114110
expect(body.rawNodeUrl).toBe(null);
115111
}
116112
});

test/unit/event.test.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,30 @@ import { H3Event } from "../../src/event.ts";
44
import { decodePathname } from "../../src/utils/internal/path.ts";
55

66
describe("H3Event URL normalization", () => {
7-
it("reuses the runtime-provided _url object in place", () => {
8-
const req = new Request("http://localhost/h%65llo");
9-
(req as any)._url = new FastURL("http://localhost/h%65llo");
7+
it("reuses the runtime-provided _url when no decoding is needed", () => {
8+
const req = new Request("http://localhost/hello");
9+
(req as any)._url = new FastURL("http://localhost/hello");
1010
const event = new H3Event(req);
1111
expect(event.url).toBe((req as any)._url);
12+
});
13+
14+
it("reuses the runtime-provided _url when decoding is an identity (reserved chars)", () => {
15+
const req = new Request("http://localhost/a%2Fb");
16+
(req as any)._url = new FastURL("http://localhost/a%2Fb");
17+
const event = new H3Event(req);
18+
expect(event.url).toBe((req as any)._url);
19+
expect(event.url.pathname).toBe("/a%2Fb");
20+
});
21+
22+
it("clones instead of mutating the runtime-provided _url when decoding", () => {
23+
const req = new Request("http://localhost/h%65llo?q=%41");
24+
(req as any)._url = new FastURL("http://localhost/h%65llo?q=%41");
25+
const event = new H3Event(req);
1226
expect(event.url.pathname).toBe("/hello");
27+
expect(event.url.search).toBe("?q=%41");
28+
expect(event.url).not.toBe((req as any)._url);
29+
// The shared parsed URL keeps the original wire encoding (#1432)
30+
expect(((req as any)._url as URL).pathname).toBe("/h%65llo");
1331
});
1432

1533
it("does not double-decode when two events share one _url", () => {
@@ -19,8 +37,8 @@ describe("H3Event URL normalization", () => {
1937
const first = new H3Event(req);
2038
expect(first.url.pathname).toBe("/a%2541-A");
2139
const second = new H3Event(req);
22-
expect(second.url).toBe(first.url);
2340
expect(second.url.pathname).toBe("/a%2541-A");
41+
expect(((req as any)._url as URL).pathname).toBe("/a%2541-%41");
2442
});
2543
});
2644

0 commit comments

Comments
 (0)