fix(static): narrow path traversal check to match `..` as a path segm… · h3js/h3@c049dc0

@@ -110,31 +110,101 @@ describe("Serve Static", () => {

110110

expect(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);

132144

expect(serveStaticOptions.getMeta).not.toHaveBeenCalledWith(

133145

expect.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");

135151

expect(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

});