diff --git a/README.md b/README.md index 4a7cfbbf2..113fdc2e5 100644 --- a/README.md +++ b/README.md @@ -459,7 +459,18 @@ const app = new express(); app.use(instance); instance.waitUntilValid(() => { - const filename = instance.getFilenameFromUrl("/bundle.js"); + let resolver; + + try { + resolved = instance.getFilenameFromUrl("/bundle.js"); + } catch (err) { + console.log(`Error: ${err}`); + } + + if (!resolved) { + console.log("Not found"); + return; + } console.log(`Filename is ${filename}`); }); diff --git a/src/index.js b/src/index.js index ccdc8fedc..de1403f83 100644 --- a/src/index.js +++ b/src/index.js @@ -131,9 +131,9 @@ const noop = () => {}; * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @callback Middleware - * @param {RequestInternal} req - * @param {ResponseInternal} res - * @param {NextFunction} next + * @param {RequestInternal} req request + * @param {ResponseInternal} res response + * @param {NextFunction} next next function * @returns {Promise} */ @@ -141,9 +141,8 @@ const noop = () => {}; /** * @callback GetFilenameFromUrl - * @param {string} url - * @param {Extra=} extra - * @returns {string | undefined} + * @param {string} url request URL + * @returns {{ filename: string, extra: Extra } | undefined} a filename with additional information, or `undefined` if nothing is found */ /** @@ -278,8 +277,7 @@ function wdm(compiler, options = {}) { (middleware(filledContext)); // API - instance.getFilenameFromUrl = (url, extra) => - getFilenameFromUrl(filledContext, url, extra); + instance.getFilenameFromUrl = (url) => getFilenameFromUrl(filledContext, url); instance.waitUntilValid = (callback = noop) => { ready(filledContext, callback); diff --git a/src/middleware.js b/src/middleware.js index 3441f3285..078bbead7 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -31,6 +31,8 @@ const ready = require("./utils/ready"); /** @typedef {import("./index.js").IncomingMessage} IncomingMessage */ /** @typedef {import("./index.js").ServerResponse} ServerResponse */ /** @typedef {import("./index.js").NormalizedHeaders} NormalizedHeaders */ +/** @typedef {import("./utils/getFilenameFromUrl.js").FilenameError} FilenameError */ +/** @typedef {import("./utils/getFilenameFromUrl.js").Extra} Extra */ /** @typedef {import("fs").ReadStream} ReadStream */ const BYTES_RANGE_REGEXP = /^ *bytes/i; @@ -498,22 +500,29 @@ function wrapper(context) { */ async function processRequest() { // Pipe and SendFile - /** @type {import("./utils/getFilenameFromUrl").Extra} */ - const extra = {}; - const filename = getFilenameFromUrl( - context, - /** @type {string} */ (getRequestURL(req)), - extra, - ); - - if (extra.errorCode) { - if (extra.errorCode === 403) { - context.logger.error(`Malicious path "${filename}".`); + /** @type {{ filename: string, extra: Extra } | undefined} */ + let resolved; + + const requestUrl = /** @type {string} */ (getRequestURL(req)); + + try { + resolved = getFilenameFromUrl(context, requestUrl); + } catch (err) { + // Fallback to 403 for unknown errors + const errorCode = + typeof err === "object" && + err !== null && + typeof (/** @type {FilenameError} */ (err).code) !== "undefined" + ? /** @type {FilenameError} */ (err).code + : 403; + + if (errorCode === 403) { + context.logger.error(`Malicious path "${requestUrl}".`); } await sendError( - extra.errorCode === 400 ? "Bad Request" : "Forbidden", - extra.errorCode, + errorCode === 400 ? "Bad Request" : "Forbidden", + errorCode, { modifyResponseData: context.options.modifyResponseData, }, @@ -521,7 +530,7 @@ function wrapper(context) { return; } - if (!filename) { + if (!resolved) { await goNext(); return; } @@ -531,7 +540,8 @@ function wrapper(context) { return; } - const { size } = /** @type {import("fs").Stats} */ (extra.stats); + const { extra, filename } = resolved; + const { size } = extra.stats; let len = size; let offset = 0; @@ -609,9 +619,7 @@ function wrapper(context) { context.options.lastModified && !getResponseHeader(res, "Last-Modified") ) { - const modified = - /** @type {import("fs").Stats} */ - (extra.stats).mtime.toUTCString(); + const modified = extra.stats.mtime.toUTCString(); setResponseHeader(res, "Last-Modified", modified); } @@ -667,7 +675,7 @@ function wrapper(context) { const result = await getETag()( isStrongETag ? /** @type {Buffer | ReadStream} */ (bufferOrStream) - : /** @type {import("fs").Stats} */ (extra.stats), + : extra.stats, ); // Because we already read stream, we can cache buffer to avoid extra read from fs diff --git a/src/utils/getFilenameFromUrl.js b/src/utils/getFilenameFromUrl.js index 44c7b7bd6..a39729b70 100644 --- a/src/utils/getFilenameFromUrl.js +++ b/src/utils/getFilenameFromUrl.js @@ -1,7 +1,5 @@ const path = require("node:path"); const querystring = require("node:querystring"); -// eslint-disable-next-line n/no-deprecated-api -const { parse } = require("node:url"); const getPaths = require("./getPaths"); const memorize = require("./memorize"); @@ -17,20 +15,31 @@ function decode(input) { return querystring.unescape(input); } -const memoizedParse = memorize(parse, undefined, (value) => { - if (value.pathname) { - value.pathname = decode(value.pathname); - } +// const memoizedParse = memorize(parse, +// undefined, +// (value) => { +// console.log({encode: value.pathname}); +// if (value.pathname) { +// value.pathname = decode(value.pathname); +// } +// console.log({decode: value.pathname}); + +// return value; +// }, +// ); + +const memoizedParse = memorize((url) => { + const urlObject = new URL(url, "http://localhost"); - return value; -}); + // We cann't change pathname in URL object directly because don't decode correctly + return { ...urlObject, pathname: decode(urlObject.pathname) }; +}, undefined); const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; /** * @typedef {object} Extra - * @property {import("fs").Stats=} stats stats - * @property {number=} errorCode error code + * @property {import("fs").Stats} stats stats * @property {boolean=} immutable true when immutable, otherwise false */ @@ -42,43 +51,55 @@ const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; * @returns {string} */ -// TODO refactor me in the next major release, this function should return `{ filename, stats, error }` +class FilenameError extends Error { + /** + * @param {string} message message + * @param {number=} code error code + */ + constructor(message, code) { + super(message); + this.name = "FilenameError"; + this.code = code; + } +} + // TODO fix redirect logic when `/` at the end, like https://github.com/pillarjs/send/blob/master/index.js#L586 /** * @template {IncomingMessage} Request * @template {ServerResponse} Response * @param {import("../index.js").FilledContext} context context * @param {string} url url - * @param {Extra=} extra extra - * @returns {string | undefined} filename + * @returns {{ filename: string, extra: Extra } | undefined} result of get filename from url */ -function getFilenameFromUrl(context, url, extra = {}) { - const { options } = context; - const paths = getPaths(context); +function getFilenameFromUrl(context, url) { + /** @type {import("node:url").URL} */ + let urlObject; /** @type {string | undefined} */ let foundFilename; - /** @type {import("node:url").Url} */ - let urlObject; try { // The `url` property of the `request` is contains only `pathname`, `search` and `hash` - urlObject = memoizedParse(url, false, true); + urlObject = memoizedParse(url); } catch { return; } + const { options } = context; + const paths = getPaths(context); + + /** @type {Extra} */ + const extra = {}; + for (const { publicPath, outputPath, assetsInfo } of paths) { /** @type {string | undefined} */ let filename; - /** @type {import("node:url").Url} */ + /** @type {import("node:url").URL} */ let publicPathObject; try { publicPathObject = memoizedParse( publicPath !== "auto" && publicPath ? publicPath : "/", - false, - true, ); } catch { continue; @@ -94,16 +115,12 @@ function getFilenameFromUrl(context, url, extra = {}) { ) { // Null byte(s) if (pathname.includes("\0")) { - extra.errorCode = 400; - - return; + throw new FilenameError("Bad Request", 400); } // ".." is malicious if (UP_PATH_REGEXP.test(path.normalize(`./${pathname}`))) { - extra.errorCode = 403; - - return; + throw new FilenameError("Forbidden", 403); } // Strip the `pathname` property from the `publicPath` option from the start of requested url @@ -161,7 +178,12 @@ function getFilenameFromUrl(context, url, extra = {}) { } } - return foundFilename; + if (!foundFilename) { + return; + } + + return { filename: foundFilename, extra }; } module.exports = getFilenameFromUrl; +module.exports.FilenameError = FilenameError; diff --git a/test/middleware.test.js b/test/middleware.test.js index 4e076031b..5aaece56f 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -573,16 +573,16 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { - expect(instance.getFilenameFromUrl("/bundle.js")).toBe( + expect(instance.getFilenameFromUrl("/bundle.js").filename).toBe( path.join(webpackConfig.output.path, "/bundle.js"), ); - expect(instance.getFilenameFromUrl("/")).toBe( + expect(instance.getFilenameFromUrl("/").filename).toBe( path.join(webpackConfig.output.path, "/index.html"), ); - expect(instance.getFilenameFromUrl("/index.html")).toBe( + expect(instance.getFilenameFromUrl("/index.html").filename).toBe( path.join(webpackConfig.output.path, "/index.html"), ); - expect(instance.getFilenameFromUrl("/svg.svg")).toBe( + expect(instance.getFilenameFromUrl("/svg.svg").filename).toBe( path.join(webpackConfig.output.path, "/svg.svg"), ); expect( @@ -617,15 +617,15 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { - expect(instance.getFilenameFromUrl("/bundle.js")).toBe( + expect(instance.getFilenameFromUrl("/bundle.js").filename).toBe( path.join(webpackConfig.output.path, "/bundle.js"), ); expect(instance.getFilenameFromUrl("/")).toBeUndefined(); - expect(instance.getFilenameFromUrl("/index.html")).toBe( + expect(instance.getFilenameFromUrl("/index.html").filename).toBe( path.join(webpackConfig.output.path, "/index.html"), ); - expect(instance.getFilenameFromUrl("/svg.svg")).toBe( + expect(instance.getFilenameFromUrl("/svg.svg").filename).toBe( path.join(webpackConfig.output.path, "/svg.svg"), ); expect( @@ -658,19 +658,23 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { expect( - instance.getFilenameFromUrl("/public/path/bundle.js"), + instance.getFilenameFromUrl("/public/path/bundle.js").filename, ).toBe( path.join(webpackPublicPathConfig.output.path, "/bundle.js"), ); - expect(instance.getFilenameFromUrl("/public/path/")).toBe( + expect( + instance.getFilenameFromUrl("/public/path/").filename, + ).toBe( path.join(webpackPublicPathConfig.output.path, "/index.html"), ); expect( - instance.getFilenameFromUrl("/public/path/index.html"), + instance.getFilenameFromUrl("/public/path/index.html").filename, ).toBe( path.join(webpackPublicPathConfig.output.path, "/index.html"), ); - expect(instance.getFilenameFromUrl("/public/path/svg.svg")).toBe( + expect( + instance.getFilenameFromUrl("/public/path/svg.svg").filename, + ).toBe( path.join(webpackPublicPathConfig.output.path, "/svg.svg"), ); @@ -704,20 +708,22 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { - expect(instance.getFilenameFromUrl("/static-one/bundle.js")).toBe( + expect( + instance.getFilenameFromUrl("/static-one/bundle.js").filename, + ).toBe( path.join(webpackMultiConfig[0].output.path, "/bundle.js"), ); - expect(instance.getFilenameFromUrl("/static-one/")).toBe( + expect(instance.getFilenameFromUrl("/static-one/").filename).toBe( path.join(webpackMultiConfig[0].output.path, "/index.html"), ); expect( - instance.getFilenameFromUrl("/static-one/index.html"), + instance.getFilenameFromUrl("/static-one/index.html").filename, ).toBe( path.join(webpackMultiConfig[0].output.path, "/index.html"), ); - expect(instance.getFilenameFromUrl("/static-one/svg.svg")).toBe( - path.join(webpackMultiConfig[0].output.path, "/svg.svg"), - ); + expect( + instance.getFilenameFromUrl("/static-one/svg.svg").filename, + ).toBe(path.join(webpackMultiConfig[0].output.path, "/svg.svg")); expect( instance.getFilenameFromUrl("/static-one/unknown.unknown"), ).toBeUndefined(); @@ -727,7 +733,9 @@ describe.each([ ), ).toBeUndefined(); - expect(instance.getFilenameFromUrl("/static-two/bundle.js")).toBe( + expect( + instance.getFilenameFromUrl("/static-two/bundle.js").filename, + ).toBe( path.join(webpackMultiConfig[1].output.path, "/bundle.js"), ); expect( diff --git a/types/index.d.ts b/types/index.d.ts index 140425b45..1ba81cd6c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -99,17 +99,16 @@ export = wdm; * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @callback Middleware - * @param {RequestInternal} req - * @param {ResponseInternal} res - * @param {NextFunction} next + * @param {RequestInternal} req request + * @param {ResponseInternal} res response + * @param {NextFunction} next next function * @returns {Promise} */ /** @typedef {import("./utils/getFilenameFromUrl").Extra} Extra */ /** * @callback GetFilenameFromUrl - * @param {string} url - * @param {Extra=} extra - * @returns {string | undefined} + * @param {string} url request URL + * @returns {{ filename: string, extra: Extra } | undefined} a filename with additional information, or `undefined` if nothing is found */ /** * @callback WaitUntilValid @@ -460,10 +459,12 @@ type Middleware< next: NextFunction, ) => Promise; type Extra = import("./utils/getFilenameFromUrl").Extra; -type GetFilenameFromUrl = ( - url: string, - extra?: Extra | undefined, -) => string | undefined; +type GetFilenameFromUrl = (url: string) => + | { + filename: string; + extra: Extra; + } + | undefined; type WaitUntilValid = (callback: Callback) => any; type Invalidate = (callback: Callback) => any; type Close = (callback: (err: Error | null | undefined) => void) => any; diff --git a/types/middleware.d.ts b/types/middleware.d.ts index cce5820ac..2be44b1de 100644 --- a/types/middleware.d.ts +++ b/types/middleware.d.ts @@ -25,6 +25,8 @@ declare namespace wrapper { IncomingMessage, ServerResponse, NormalizedHeaders, + FilenameError, + Extra, ReadStream, }; } @@ -50,4 +52,6 @@ type NextFunction = import("./index.js").NextFunction; type IncomingMessage = import("./index.js").IncomingMessage; type ServerResponse = import("./index.js").ServerResponse; type NormalizedHeaders = import("./index.js").NormalizedHeaders; +type FilenameError = import("./utils/getFilenameFromUrl.js").FilenameError; +type Extra = import("./utils/getFilenameFromUrl.js").Extra; type ReadStream = import("fs").ReadStream; diff --git a/types/utils/getFilenameFromUrl.d.ts b/types/utils/getFilenameFromUrl.d.ts index be28a0ae0..2385f8646 100644 --- a/types/utils/getFilenameFromUrl.d.ts +++ b/types/utils/getFilenameFromUrl.d.ts @@ -1,24 +1,10 @@ export = getFilenameFromUrl; -/** - * @typedef {object} Extra - * @property {import("fs").Stats=} stats stats - * @property {number=} errorCode error code - * @property {boolean=} immutable true when immutable, otherwise false - */ -/** - * decodeURIComponent. - * - * Allows V8 to only deoptimize this fn instead of all of send(). - * @param {string} input - * @returns {string} - */ /** * @template {IncomingMessage} Request * @template {ServerResponse} Response * @param {import("../index.js").FilledContext} context context * @param {string} url url - * @param {Extra=} extra extra - * @returns {string | undefined} filename + * @returns {{ filename: string, extra: Extra } | undefined} result of get filename from url */ declare function getFilenameFromUrl< Request extends IncomingMessage, @@ -26,10 +12,34 @@ declare function getFilenameFromUrl< >( context: import("../index.js").FilledContext, url: string, - extra?: Extra | undefined, -): string | undefined; +): + | { + filename: string; + extra: Extra; + } + | undefined; declare namespace getFilenameFromUrl { - export { IncomingMessage, ServerResponse, Extra }; + export { FilenameError, IncomingMessage, ServerResponse, Extra }; +} +/** + * @typedef {object} Extra + * @property {import("fs").Stats} stats stats + * @property {boolean=} immutable true when immutable, otherwise false + */ +/** + * decodeURIComponent. + * + * Allows V8 to only deoptimize this fn instead of all of send(). + * @param {string} input + * @returns {string} + */ +declare class FilenameError extends Error { + /** + * @param {string} message message + * @param {number=} code error code + */ + constructor(message: string, code?: number | undefined); + code: number | undefined; } type IncomingMessage = import("../index.js").IncomingMessage; type ServerResponse = import("../index.js").ServerResponse; @@ -37,11 +47,7 @@ type Extra = { /** * stats */ - stats?: import("fs").Stats | undefined; - /** - * error code - */ - errorCode?: number | undefined; + stats: import("fs").Stats; /** * true when immutable, otherwise false */