From 84e3135b538ba601a33d48e59e87dec1f1d3cd3d Mon Sep 17 00:00:00 2001 From: neverland Date: Thu, 22 Oct 2026 11:09:50 +0800 Subject: [PATCH 1/2] fix: validate paths after stripping publicPath --- src/utils/getFilenameFromUrl.js | 30 ++++++++++-------------------- test/middleware.test.js | 6 ++++++ 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/utils/getFilenameFromUrl.js b/src/utils/getFilenameFromUrl.js index d5b20c7..aa4587a 100644 --- a/src/utils/getFilenameFromUrl.js +++ b/src/utils/getFilenameFromUrl.js @@ -118,33 +118,24 @@ function getFilenameFromUrl(context, url) { throw new FilenameError("Bad Request", 400); } - // Only decoded paths containing two consecutive dots can produce a - // parent-directory segment through `path.join`. - const mayHaveUpPath = pathname.includes(".."); + const relativePathname = pathname.slice(publicPathPathname.length); + + // Stripping a public path without a trailing slash can turn + // `/assets../secret` into `../secret`. Check the remainder before joining. + const mayHaveUpPath = relativePathname.includes(".."); - // ".." is malicious if ( mayHaveUpPath && - UP_PATH_REGEXP.test(path.normalize(`./${pathname}`)) + UP_PATH_REGEXP.test(path.normalize(`./${relativePathname}`)) ) { throw new FilenameError("Forbidden", 403); } - // Strip the `pathname` property from the `publicPath` option from the start of requested url - // `/complex/foo.js` => `foo.js` - // and add outputPath - // `foo.js` => `/home/user/my-project/dist/foo.js` - filename = path.join( - outputPath, - pathname.slice(publicPathPathname.length), - ); + filename = path.join(outputPath, relativePathname); if (mayHaveUpPath) { - // A public path without a trailing slash can consume the characters - // before `..`, turning a safe-looking pathname such as - // `/assets../secret` into `../secret` after the prefix is stripped. - // Validate the final candidate while keeping ordinary requests on the - // fast path. + // Keep the final containment check as a backstop for platform-specific + // path behavior, without adding work for ordinary requests. const relative = path.relative(outputPath, filename); if (relative === ".." || relative.startsWith(`..${path.sep}`)) { @@ -163,9 +154,8 @@ function getFilenameFromUrl(context, url) { // Lazy evaluate immutable because it may be expensive to get asset info get immutable() { if (immutable === undefined) { - const assetName = pathname.slice(publicPathPathname.length); immutable = Boolean( - compilation.getAsset(assetName)?.info?.immutable, + compilation.getAsset(relativePathname)?.info?.immutable, ); } return immutable; diff --git a/test/middleware.test.js b/test/middleware.test.js index e033731..53e4fba 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -990,6 +990,12 @@ describe.each([ expect(response.statusCode).toBe(403); expect(response.text).not.toContain("TOP SECRET"); }); + + it("should reject a path that leaves and re-enters the output path", async () => { + const response = await req.get("/static../dist/index.html"); + + expect(response.statusCode).toBe(403); + }); }); describe("should work", () => { From d7eadb5d0cb2d00646b59cdb45e5f44c6fb0ffef Mon Sep 17 00:00:00 2001 From: neverland Date: Thu, 22 Oct 2026 11:27:15 +0800 Subject: [PATCH 2/2] docs: preserve path validation comments --- src/utils/getFilenameFromUrl.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/getFilenameFromUrl.js b/src/utils/getFilenameFromUrl.js index aa4587a..4316027 100644 --- a/src/utils/getFilenameFromUrl.js +++ b/src/utils/getFilenameFromUrl.js @@ -118,12 +118,16 @@ function getFilenameFromUrl(context, url) { throw new FilenameError("Bad Request", 400); } + // Strip the public path prefix from the decoded pathname: + // `/complex/foo.js` => `foo.js`. const relativePathname = pathname.slice(publicPathPathname.length); - // Stripping a public path without a trailing slash can turn - // `/assets../secret` into `../secret`. Check the remainder before joining. + // Only decoded paths containing two consecutive dots can produce a + // parent-directory segment through `path.join`. const mayHaveUpPath = relativePathname.includes(".."); + // Stripping a public path without a trailing slash can turn + // `/assets../secret` into `../secret`. Check the remainder before joining. if ( mayHaveUpPath && UP_PATH_REGEXP.test(path.normalize(`./${relativePathname}`)) @@ -131,6 +135,7 @@ function getFilenameFromUrl(context, url) { throw new FilenameError("Forbidden", 403); } + // Add outputPath: `foo.js` => `/home/user/my-project/dist/foo.js`. filename = path.join(outputPath, relativePathname); if (mayHaveUpPath) {