Skip to content

Commit bd8c0d3

Browse files
committed
fix(fasturl, node): normalize pathname if necessary
1 parent a8a0225 commit bd8c0d3

File tree

5 files changed

+132
-0
lines changed

5 files changed

+132
-0
lines changed

‎src/_url.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ export type URLInit = {
77
search: string;
88
};
99

10+
// Matches paths that need native URL normalization:
11+
// - dot segments (. / .. / %2e variants)
12+
// - backslashes
13+
// - non-ASCII characters
14+
const _needsNormRE = /(?:(?:^|\/)(?:\.|\.\.|%2e|%2e\.|\.%2e|%2e%2e)(?:\/|$))|[\\^\x80-\uffff]/i;
15+
1016
/**
1117
* URL wrapper with fast paths to access to the following props:
1218
*
@@ -38,6 +44,10 @@ export const FastURL: { new (url: string | URLInit): URL & { _url: URL } } =
3844
constructor(url: string | URLInit) {
3945
if (typeof url === "string") {
4046
this.#href = url;
47+
} else if (_needsNormRE.test(url.pathname)) {
48+
this.#url = new NativeURL(
49+
`${url.protocol || "http:"}//${url.host || "localhost"}${url.pathname}${url.search || ""}`,
50+
);
4151
} else {
4252
this.#protocol = url.protocol;
4353
this.#host = url.host;

‎test/_fixture.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,12 @@ export const fixture: (
257257
headers: inspect(req.headers),
258258
});
259259
}
260+
case "/bar/baz": {
261+
return Response.json({
262+
pathname: url.pathname,
263+
url: req.url,
264+
});
265+
}
260266
}
261267
return new _Response("404", { status: 404 });
262268
},

‎test/_tests.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { describe, expect, test } from "vitest";
22
import http from "node:http";
3+
import http2 from "node:http2";
34

45
export function addTests(opts: {
56
url: (path: string) => string;
67
runtime: string;
78
fetch?: typeof globalThis.fetch;
89
http2?: boolean;
910
fastResponse?: boolean;
11+
ca?: string;
1012
}): void {
1113
const { url, fetch: _fetch = globalThis.fetch } = opts;
1214

@@ -283,6 +285,61 @@ export function addTests(opts: {
283285
expect(data.includes("x-foo"));
284286
});
285287

288+
test("normalize traversal in request path", async () => {
289+
const _url = new URL(url("/"));
290+
291+
let body: string;
292+
let statusCode: number | undefined;
293+
294+
if (opts.http2) {
295+
const client = http2.connect(_url.origin, { ca: opts.ca });
296+
try {
297+
const req = client.request({ ":path": "/foo/../bar/baz", ":authority": "localhost" });
298+
const res = await new Promise<{ headers: http2.IncomingHttpHeaders; body: string }>(
299+
(resolve, reject) => {
300+
let headers: http2.IncomingHttpHeaders;
301+
const chunks: Uint8Array[] = [];
302+
req.on("response", (h) => {
303+
headers = h;
304+
});
305+
req.on("data", (chunk: Uint8Array) => chunks.push(chunk));
306+
req.on("end", () =>
307+
resolve({ headers: headers!, body: Buffer.concat(chunks).toString("utf8") }),
308+
);
309+
req.on("error", reject);
310+
},
311+
);
312+
statusCode = res.headers[":status"] as number | undefined;
313+
body = res.body;
314+
} finally {
315+
client.close();
316+
}
317+
} else {
318+
const res = await new Promise<http.IncomingMessage>((resolve, reject) => {
319+
const req = http.request({
320+
method: "GET",
321+
path: "/foo/../bar/baz",
322+
hostname: "localhost",
323+
port: _url.port,
324+
});
325+
req.end();
326+
req.on("response", resolve);
327+
req.on("error", reject);
328+
});
329+
statusCode = res.statusCode;
330+
body = await new Promise<string>((resolve, reject) => {
331+
const chunks: Uint8Array[] = [];
332+
res.on("data", (chunk) => chunks.push(chunk));
333+
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
334+
res.on("error", reject);
335+
});
336+
}
337+
338+
expect(statusCode).toBe(200);
339+
const data = JSON.parse(body);
340+
expect(data.pathname).toBe("/bar/baz");
341+
});
342+
286343
// TODO: Write test to make sure it is forbidden for http2/tls
287344
test.skipIf(opts.http2)("absolute path in request line", async () => {
288345
const _url = new URL(url("/"));

‎test/node.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ for (const config of testConfigs) {
7171
http2: config.http2,
7272
fetch: client.fetch,
7373
fastResponse: config.fastResponse,
74+
ca: tls.ca,
7475
});
7576
});
7677
}

‎test/url.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, test, expect } from "vitest";
22
import { FastURL } from "../src/_url.ts";
3+
import { NodeRequestURL } from "../src/adapters/_node/url.ts";
34

45
const urlTests = await import("./wpt/url_tests.json", {
56
with: { type: "json" },
@@ -123,4 +124,61 @@ describe("FastURL", () => {
123124
});
124125
}
125126
});
127+
128+
describe("pathname normalization", () => {
129+
const cases = [
130+
// Literal dot segments
131+
["/foo/../bar/baz", "/bar/baz"],
132+
["/foo/./bar", "/foo/bar"],
133+
["/a/b/../c/../d", "/a/d"],
134+
["/a/b/../../c", "/c"],
135+
["/../a", "/a"],
136+
["/a/..", "/"],
137+
["/a/.", "/a/"],
138+
["/a/b/../c?q=1", "/a/c"],
139+
// Percent-encoded dot segments
140+
["/%2e/b", "/b"],
141+
["/%2E/b", "/b"],
142+
["/%2e%2e/b", "/b"],
143+
["/%2E%2E/b", "/b"],
144+
["/a/%2e%2e/b", "/b"],
145+
["/a/%2e./b", "/b"],
146+
["/a/.%2e/b", "/b"],
147+
["/a/.%2E/b", "/b"],
148+
["/a/%2E./b", "/b"],
149+
["/a/%2e", "/a/"],
150+
["/a/%2e%2e", "/"],
151+
// Trailing encoded dot produces trailing slash
152+
["/a/b/%2e", "/a/b/"],
153+
["/a/b/%2e%2e", "/a/"],
154+
// Mixed
155+
["/a/%2e/../b", "/b"],
156+
["/a/./%2e%2e/b", "/b"],
157+
// Backslash normalization
158+
["/a\\b", "/a/b"],
159+
["/a\\b\\c", "/a/b/c"],
160+
["/a\\b/c", "/a/b/c"],
161+
// Non-ASCII characters (percent-encoded by native URL)
162+
["/caf\u00e9", "/caf%C3%A9"],
163+
["/\u00fc\u00f6\u00e4", "/%C3%BC%C3%B6%C3%A4"],
164+
// Not dot segments (should be untouched)
165+
["/a/b/c", "/a/b/c"],
166+
["/.hidden", "/.hidden"],
167+
["/a/.hidden/b", "/a/.hidden/b"],
168+
] as const;
169+
170+
for (const [input, expected] of cases) {
171+
test(`native URL: "${input}" => "${expected}"`, () => {
172+
const url = new URL(`http://localhost${input}`);
173+
expect(url.pathname).toBe(expected);
174+
});
175+
176+
test(`NodeRequestURL: "${input}" => "${expected}"`, () => {
177+
const url = new NodeRequestURL({
178+
req: { url: input, headers: { host: "localhost" } } as any,
179+
});
180+
expect(url.pathname).toBe(expected);
181+
});
182+
}
183+
});
126184
});

0 commit comments

Comments
 (0)