fix(static): narrow path traversal check to match `..` as a path segm… · h3js/h3@c049dc0
@@ -110,31 +110,101 @@ describe("Serve Static", () => {
110110expect(res.status).toEqual(405);
111111});
112112113-it("Prevents path traversal via percent-encoded dot segments", async () => {
114-const listener = toNodeListener(app);
115-const server = await import("node:http").then((m) =>
116-m.createServer(listener),
117-);
118-await new Promise<void>((resolve) => server.listen(0, resolve));
119-const port = (server.address() as any).port;
120-try {
121-const res = await new Promise<{ statusCode: number }>((resolve) => {
122-httpRequest(
123-{
124-hostname: "127.0.0.1",
125- port,
126-path: "/%2e%2e/%2e%2e/etc/passwd",
127-method: "GET",
128-},
129-(res) => resolve({ statusCode: res.statusCode! }),
130-).end();
131-});
113+describe("Path traversal prevention", () => {
114+// Helper to send raw HTTP requests without URL normalization
115+async function rawRequest(path: string) {
116+const listener = toNodeListener(app);
117+const server = await import("node:http").then((m) =>
118+m.createServer(listener),
119+);
120+await new Promise<void>((resolve) => server.listen(0, resolve));
121+const port = (server.address() as any).port;
122+try {
123+return await new Promise<{ statusCode: number }>((resolve) => {
124+httpRequest(
125+{ hostname: "127.0.0.1", port, path, method: "GET" },
126+(res) => resolve({ statusCode: res.statusCode! }),
127+).end();
128+});
129+} finally {
130+server.close();
131+}
132+}
133+134+// --- Blocked paths (must return 404) ---
135+136+it("blocks basic ../", async () => {
137+const res = await rawRequest("/../etc/passwd");
138+expect(res.statusCode).toEqual(404);
139+});
140+141+it("blocks percent-encoded dot segments (%2e%2e)", async () => {
142+const res = await rawRequest("/%2e%2e/%2e%2e/etc/passwd");
143+expect(res.statusCode).toEqual(404);
132144expect(serveStaticOptions.getMeta).not.toHaveBeenCalledWith(
133145expect.stringContaining(".."),
134146);
147+});
148+149+it("blocks mixed-case percent-encoded dots (%2E%2E)", async () => {
150+const res = await rawRequest("/%2E%2E/%2E%2E/etc/passwd");
135151expect(res.statusCode).toEqual(404);
136-} finally {
137-server.close();
138-}
152+});
153+154+it("blocks mid-path traversal", async () => {
155+const res = await rawRequest("/assets/../../etc/passwd");
156+expect(res.statusCode).toEqual(404);
157+});
158+159+it("blocks trailing /.. segment", async () => {
160+const res = await rawRequest("/assets/..");
161+expect(res.statusCode).toEqual(404);
162+});
163+164+it("blocks backslash traversal (..\\)", async () => {
165+const res = await rawRequest("/..\\etc\\passwd");
166+expect(res.statusCode).toEqual(404);
167+});
168+169+it("blocks encoded backslash traversal (..%5c)", async () => {
170+const res = await rawRequest("/..%5c..%5cetc%5cpasswd");
171+expect(res.statusCode).toEqual(404);
172+});
173+174+it("blocks double-dot only segment", async () => {
175+const res = await rawRequest("/..");
176+expect(res.statusCode).toEqual(404);
177+});
178+179+// --- Allowed paths (must NOT be blocked) ---
180+181+it("allows filenames with consecutive dots (e.g. _...grid)", async () => {
182+const res = await request.get("/_...grid_123.js");
183+expect(res.status).toEqual(200);
184+expect(res.text).toContain("asset:/_...grid_123.js");
185+});
186+187+it("allows filenames with double dots (e.g. file..name.js)", async () => {
188+const res = await request.get("/file..name.js");
189+expect(res.status).toEqual(200);
190+expect(res.text).toContain("asset:/file..name.js");
191+});
192+193+it("allows dotfiles (e.g. .hidden)", async () => {
194+const res = await request.get("/.hidden");
195+expect(res.status).toEqual(200);
196+expect(res.text).toContain("asset:/.hidden");
197+});
198+199+it("allows single dot in path", async () => {
200+const res = await request.get("/assets/file.txt");
201+expect(res.status).toEqual(200);
202+});
203+204+it("allows ... directory name", async () => {
205+const res = await request.get("/...test/file.js");
206+expect(res.status).toEqual(200);
207+expect(res.text).toContain("asset:/...test/file.js");
208+});
139209});
140210});