webpack-dev-middleware Path Traversal vulnerability
Description
Prior to versions 7.1.0, 6.1.2, and 5.3.4, the webpack-dev-middleware development middleware for devpack does not validate the supplied URL address sufficiently before returning the local file. It is possible to access any file on the developer's machine. The middleware can either work with the physical filesystem when reading the files or it can use a virtualized in-memory memfs filesystem. If writeToDisk configuration option is set to true, the physical filesystem is used. The getFilenameFromUrl method is used to parse URL and build the local file path. The public path prefix is stripped from the URL, and the unsecaped path suffix is appended to the outputPath. As the URL is not unescaped and normalized automatically before calling the midlleware, it is possible to use %2e and %2f sequences to perform path traversal attack.
Developers using webpack-dev-server or webpack-dev-middleware are affected by the issue. When the project is started, an attacker might access any file on the developer's machine and exfiltrate the content. If the development server is listening on a public IP address (or 0.0.0.0), an attacker on the local network can access the local files without any interaction from the victim (direct connection to the port). If the server allows access from third-party domains, an attacker can send a malicious link to the victim. When visited, the client side script can connect to the local server and exfiltrate the local files. Starting with fixed versions 7.1.0, 6.1.2, and 5.3.4, the URL is unescaped and normalized before any further processing.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
webpack-dev-middlewarenpm | >= 7.0.0, < 7.1.0 | 7.1.0 |
webpack-dev-middlewarenpm | >= 6.0.0, < 6.1.2 | 6.1.2 |
webpack-dev-middlewarenpm | < 5.3.4 | 5.3.4 |
Affected products
1- Range: >= 7.0.0, < 7.1.0
Patches
3189c4ac7d234fix(security): do not allow to read files above (#1779)
5 files changed · +225 −26
src/middleware.js+15 −1 modified@@ -11,6 +11,7 @@ const { setHeaderForResponse, setStatusCode, send, + sendError, } = require("./utils/compatibleAPI"); const ready = require("./utils/ready"); @@ -95,9 +96,12 @@ function wrapper(context) { } async function processRequest() { + /** @type {import("./utils/getFilenameFromUrl").Extra} */ + const extra = {}; const filename = getFilenameFromUrl( context, - /** @type {string} */ (req.url) + /** @type {string} */ (req.url), + extra ); if (!filename) { @@ -106,6 +110,16 @@ function wrapper(context) { return; } + if (extra.errorCode) { + if (extra.errorCode === 403) { + context.logger.error(`Malicious path "${filename}".`); + } + + sendError(req, res, extra.errorCode); + + return; + } + let { headers } = context.options; if (typeof headers === "function") {
src/utils/compatibleAPI.js+116 −0 modified@@ -155,11 +155,127 @@ function send(req, res, bufferOtStream, byteLength) { } } +/** + * @template {ServerResponse} Response + * @param {Response} res + */ +function clearHeadersForResponse(res) { + const headers = getHeaderNames(res); + + for (let i = 0; i < headers.length; i++) { + res.removeHeader(headers[i]); + } +} + +const matchHtmlRegExp = /["'&<>]/; + +/** + * @param {string} string raw HTML + * @returns {string} escaped HTML + */ +function escapeHtml(string) { + const str = `${string}`; + const match = matchHtmlRegExp.exec(str); + + if (!match) { + return str; + } + + let escape; + let html = ""; + let index = 0; + let lastIndex = 0; + + for ({ index } = match; index < str.length; index++) { + switch (str.charCodeAt(index)) { + // " + case 34: + escape = """; + break; + // & + case 38: + escape = "&"; + break; + // ' + case 39: + escape = "'"; + break; + // < + case 60: + escape = "<"; + break; + // > + case 62: + escape = ">"; + break; + default: + // eslint-disable-next-line no-continue + continue; + } + + if (lastIndex !== index) { + html += str.substring(lastIndex, index); + } + + lastIndex = index + 1; + html += escape; + } + + return lastIndex !== index ? html + str.substring(lastIndex, index) : html; +} + +/** @type {Record<number, string>} */ +const statuses = { + 400: "Bad Request", + 403: "Forbidden", + 404: "Not Found", + 416: "Range Not Satisfiable", + 500: "Internal Server Error", +}; + +/** + * @template {IncomingMessage} Request + * @template {ServerResponse} Response + * @param {Request} req response + * @param {Response} res response + * @param {number} status status + * @returns {void} + */ +function sendError(req, res, status) { + const content = statuses[status] || String(status); + const document = `<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Error</title> +</head> +<body> +<pre>${escapeHtml(content)}</pre> +</body> +</html>`; + + // Clear existing headers + clearHeadersForResponse(res); + + // Send basic response + setStatusCode(res, status); + setHeaderForResponse(res, "Content-Type", "text/html; charset=utf-8"); + setHeaderForResponse(res, "Content-Security-Policy", "default-src 'none'"); + setHeaderForResponse(res, "X-Content-Type-Options", "nosniff"); + + const byteLength = Buffer.byteLength(document); + + setHeaderForResponse(res, "Content-Length", byteLength); + + res.end(document); +} + module.exports = { getHeaderNames, getHeaderFromRequest, getHeaderFromResponse, setHeaderForResponse, setStatusCode, send, + sendError, };
src/utils/getFilenameFromUrl.js+74 −23 modified@@ -10,11 +10,14 @@ const getPaths = require("./getPaths"); const cacheStore = new WeakMap(); /** + * @template T * @param {Function} fn - * @param {{ cache?: Map<any, any> }} [cache] + * @param {{ cache?: Map<string, { data: T }> } | undefined} cache + * @param {(value: T) => T} callback * @returns {any} */ -const mem = (fn, { cache = new Map() } = {}) => { +// @ts-ignore +const mem = (fn, { cache = new Map() } = {}, callback) => { /** * @param {any} arguments_ * @return {any} @@ -27,7 +30,8 @@ const mem = (fn, { cache = new Map() } = {}) => { return cacheItem.data; } - const result = fn.apply(this, arguments_); + let result = fn.apply(this, arguments_); + result = callback(result); cache.set(key, { data: result, @@ -40,20 +44,52 @@ const mem = (fn, { cache = new Map() } = {}) => { return memoized; }; -const memoizedParse = mem(parse); +// eslint-disable-next-line no-undefined +const memoizedParse = mem(parse, undefined, (value) => { + if (value.pathname) { + // eslint-disable-next-line no-param-reassign + value.pathname = decode(value.pathname); + } + + return value; +}); + +const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; + +/** + * @typedef {Object} Extra + * @property {import("fs").Stats=} stats + * @property {number=} errorCode + */ + +/** + * decodeURIComponent. + * + * Allows V8 to only deoptimize this fn instead of all of send(). + * + * @param {string} input + * @returns {string} + */ + +function decode(input) { + return querystring.unescape(input); +} /** * @template {IncomingMessage} Request * @template {ServerResponse} Response * @param {import("../index.js").Context<Request, Response>} context * @param {string} url + * @param {Extra=} extra * @returns {string | undefined} */ -function getFilenameFromUrl(context, url) { +function getFilenameFromUrl(context, url, extra = {}) { const { options } = context; const paths = getPaths(context); + /** @type {string | undefined} */ let foundFilename; + /** @type {URL} */ let urlObject; try { @@ -64,7 +100,9 @@ function getFilenameFromUrl(context, url) { } for (const { publicPath, outputPath } of paths) { + /** @type {string | undefined} */ let filename; + /** @type {URL} */ let publicPathObject; try { @@ -78,39 +116,51 @@ function getFilenameFromUrl(context, url) { continue; } - if ( - urlObject.pathname && - urlObject.pathname.startsWith(publicPathObject.pathname) - ) { - filename = outputPath; + const { pathname } = urlObject; + const { pathname: publicPathPathname } = publicPathObject; - // Strip the `pathname` property from the `publicPath` option from the start of requested url - // `/complex/foo.js` => `foo.js` - const pathname = urlObject.pathname.slice( - publicPathObject.pathname.length - ); + if (pathname && pathname.startsWith(publicPathPathname)) { + // Null byte(s) + if (pathname.includes("\0")) { + // eslint-disable-next-line no-param-reassign + extra.errorCode = 400; + + return; + } + + // ".." is malicious + if (UP_PATH_REGEXP.test(path.normalize(`./${pathname}`))) { + // eslint-disable-next-line no-param-reassign + extra.errorCode = 403; - if (pathname) { - filename = path.join(outputPath, querystring.unescape(pathname)); + return; } - let fsStats; + // 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) + ); try { - fsStats = + // eslint-disable-next-line no-param-reassign + extra.stats = /** @type {import("fs").statSync} */ (context.outputFileSystem.statSync)(filename); } catch (_ignoreError) { // eslint-disable-next-line no-continue continue; } - if (fsStats.isFile()) { + if (extra.stats.isFile()) { foundFilename = filename; break; } else if ( - fsStats.isDirectory() && + extra.stats.isDirectory() && (typeof options.index === "undefined" || options.index) ) { const indexValue = @@ -122,15 +172,16 @@ function getFilenameFromUrl(context, url) { filename = path.join(filename, indexValue); try { - fsStats = + // eslint-disable-next-line no-param-reassign + extra.stats = /** @type {import("fs").statSync} */ (context.outputFileSystem.statSync)(filename); } catch (__ignoreError) { // eslint-disable-next-line no-continue continue; } - if (fsStats.isFile()) { + if (extra.stats.isFile()) { foundFilename = filename; break;
types/utils/compatibleAPI.d.ts+12 −0 modified@@ -84,3 +84,15 @@ export function send< bufferOtStream: string | Buffer | import("fs").ReadStream, byteLength: number ): void; +/** + * @template {IncomingMessage} Request + * @template {ServerResponse} Response + * @param {Request} req response + * @param {Response} res response + * @param {number} status status + * @returns {void} + */ +export function sendError< + Request_1 extends import("http").IncomingMessage, + Response_1 extends import("../index.js").ServerResponse +>(req: Request_1, res: Response_1, status: number): void;
types/utils/getFilenameFromUrl.d.ts+8 −2 modified@@ -5,17 +5,23 @@ export = getFilenameFromUrl; * @template {ServerResponse} Response * @param {import("../index.js").Context<Request, Response>} context * @param {string} url + * @param {Extra=} extra * @returns {string | undefined} */ declare function getFilenameFromUrl< Request_1 extends import("http").IncomingMessage, Response_1 extends import("../index.js").ServerResponse >( context: import("../index.js").Context<Request_1, Response_1>, - url: string + url: string, + extra?: Extra | undefined ): string | undefined; declare namespace getFilenameFromUrl { - export { IncomingMessage, ServerResponse }; + export { Extra, IncomingMessage, ServerResponse }; } +type Extra = { + stats?: import("fs").Stats | undefined; + errorCode?: number | undefined; +}; type IncomingMessage = import("../index.js").IncomingMessage; type ServerResponse = import("../index.js").ServerResponse;
9670b3495da5fix(security): do not allow to read files above (#1778)
6 files changed · +14832 −55
package-lock.json+14535 −30 modifiedsrc/middleware.js+15 −0 modified@@ -10,6 +10,7 @@ const { setHeaderForResponse, setStatusCode, send, + sendError, } = require("./utils/compatibleAPI"); const ready = require("./utils/ready"); @@ -94,6 +95,8 @@ function wrapper(context) { } async function processRequest() { + /** @type {import("./utils/getFilenameFromUrl").Extra} */ + const extra = {}; const filename = getFilenameFromUrl( context, /** @type {string} */ (req.url) @@ -105,6 +108,18 @@ function wrapper(context) { return; } + if (extra.errorCode) { + if (extra.errorCode === 403) { + context.logger.error(`Malicious path "${filename}".`); + } + + sendError(req, res, extra.errorCode, { + modifyResponseData: context.options.modifyResponseData, + }); + + return; + } + let { headers } = context.options; if (typeof headers === "function") {
src/utils/compatibleAPI.js+154 −0 modified@@ -155,11 +155,165 @@ function send(req, res, bufferOtStream, byteLength) { } } +/** + * @template {ServerResponse} Response + * @param {Response} res + */ +function clearHeadersForResponse(res) { + const headers = getHeaderNames(res); + + for (let i = 0; i < headers.length; i++) { + res.removeHeader(headers[i]); + } +} + +/** + * @template {ServerResponse} Response + * @param {Response} res + * @param {Record<string, number | string | string[] | undefined>} headers + */ +function setHeadersForResponse(res, headers) { + const keys = Object.keys(headers); + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const value = headers[key]; + + if (typeof value !== "undefined") { + setHeaderForResponse(res, key, value); + } + } +} + +const matchHtmlRegExp = /["'&<>]/; + +/** + * @param {string} string raw HTML + * @returns {string} escaped HTML + */ +function escapeHtml(string) { + const str = `${string}`; + const match = matchHtmlRegExp.exec(str); + + if (!match) { + return str; + } + + let escape; + let html = ""; + let index = 0; + let lastIndex = 0; + + for ({ index } = match; index < str.length; index++) { + switch (str.charCodeAt(index)) { + // " + case 34: + escape = """; + break; + // & + case 38: + escape = "&"; + break; + // ' + case 39: + escape = "'"; + break; + // < + case 60: + escape = "<"; + break; + // > + case 62: + escape = ">"; + break; + default: + // eslint-disable-next-line no-continue + continue; + } + + if (lastIndex !== index) { + html += str.substring(lastIndex, index); + } + + lastIndex = index + 1; + html += escape; + } + + return lastIndex !== index ? html + str.substring(lastIndex, index) : html; +} + +/** @type {Record<number, string>} */ +const statuses = { + 400: "Bad Request", + 403: "Forbidden", + 404: "Not Found", + 416: "Range Not Satisfiable", + 500: "Internal Server Error", +}; + +/** + * @template {IncomingMessage} Request + * @template {ServerResponse} Response + * @typedef {Object} SendOptions send error options + * @property {Record<string, number | string | string[] | undefined>=} headers headers + * @property {import("../index").ModifyResponseData<Request, Response>=} modifyResponseData modify response data callback + * @property {import("../index").OutputFileSystem} outputFileSystem modify response data callback + */ + +/** + * @template {IncomingMessage} Request + * @template {ServerResponse} Response + * @param {Request} req response + * @param {Response} res response + * @param {number} status status + * @param {Partial<SendOptions<Request, Response>>=} options options + * @returns {void} + */ +function sendError(req, res, status, options) { + const content = statuses[status] || String(status); + let document = `<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Error</title> +</head> +<body> +<pre>${escapeHtml(content)}</pre> +</body> +</html>`; + + // Clear existing headers + clearHeadersForResponse(res); + + if (options && options.headers) { + setHeadersForResponse(res, options.headers); + } + + // Send basic response + setStatusCode(res, status); + setHeaderForResponse(res, "Content-Type", "text/html; charset=utf-8"); + setHeaderForResponse(res, "Content-Security-Policy", "default-src 'none'"); + setHeaderForResponse(res, "X-Content-Type-Options", "nosniff"); + + let byteLength = Buffer.byteLength(document); + + if (options && options.modifyResponseData) { + ({ data: document, byteLength } = + /** @type {{data: string, byteLength: number }} */ + (options.modifyResponseData(req, res, document, byteLength))); + } + + setHeaderForResponse(res, "Content-Length", byteLength); + + res.end(document); +} + module.exports = { getHeaderNames, getHeaderFromRequest, getHeaderFromResponse, setHeaderForResponse, setStatusCode, send, + sendError, };
src/utils/getFilenameFromUrl.js+72 −23 modified@@ -10,12 +10,14 @@ const getPaths = require("./getPaths"); const cacheStore = new WeakMap(); /** + * @template T * @param {Function} fn - * @param {{ cache?: Map<any, any> }} [cache] + * @param {{ cache?: Map<string, { data: T }> } | undefined} cache + * @param {(value: T) => T} callback * @returns {any} */ // @ts-ignore -const mem = (fn, { cache = new Map() } = {}) => { +const mem = (fn, { cache = new Map() } = {}, callback) => { /** * @param {any} arguments_ * @return {any} @@ -28,7 +30,8 @@ const mem = (fn, { cache = new Map() } = {}) => { return cacheItem.data; } - const result = fn.apply(this, arguments_); + let result = fn.apply(this, arguments_); + result = callback(result); cache.set(key, { data: result, @@ -41,20 +44,52 @@ const mem = (fn, { cache = new Map() } = {}) => { return memoized; }; -const memoizedParse = mem(parse); +// eslint-disable-next-line no-undefined +const memoizedParse = mem(parse, undefined, (value) => { + if (value.pathname) { + // eslint-disable-next-line no-param-reassign + value.pathname = decode(value.pathname); + } + + return value; +}); + +const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; + +/** + * @typedef {Object} Extra + * @property {import("fs").Stats=} stats + * @property {number=} errorCode + */ + +/** + * decodeURIComponent. + * + * Allows V8 to only deoptimize this fn instead of all of send(). + * + * @param {string} input + * @returns {string} + */ + +function decode(input) { + return querystring.unescape(input); +} /** * @template {IncomingMessage} Request * @template {ServerResponse} Response * @param {import("../index.js").Context<Request, Response>} context * @param {string} url + * @param {Extra=} extra * @returns {string | undefined} */ -function getFilenameFromUrl(context, url) { +function getFilenameFromUrl(context, url, extra = {}) { const { options } = context; const paths = getPaths(context); + /** @type {string | undefined} */ let foundFilename; + /** @type {URL} */ let urlObject; try { @@ -65,7 +100,9 @@ function getFilenameFromUrl(context, url) { } for (const { publicPath, outputPath } of paths) { + /** @type {string | undefined} */ let filename; + /** @type {URL} */ let publicPathObject; try { @@ -79,39 +116,51 @@ function getFilenameFromUrl(context, url) { continue; } - if ( - urlObject.pathname && - urlObject.pathname.startsWith(publicPathObject.pathname) - ) { - filename = outputPath; + const { pathname } = urlObject; + const { pathname: publicPathPathname } = publicPathObject; - // Strip the `pathname` property from the `publicPath` option from the start of requested url - // `/complex/foo.js` => `foo.js` - const pathname = urlObject.pathname.slice( - publicPathObject.pathname.length - ); + if (pathname && pathname.startsWith(publicPathPathname)) { + // Null byte(s) + if (pathname.includes("\0")) { + // eslint-disable-next-line no-param-reassign + extra.errorCode = 400; + + return; + } + + // ".." is malicious + if (UP_PATH_REGEXP.test(path.normalize(`./${pathname}`))) { + // eslint-disable-next-line no-param-reassign + extra.errorCode = 403; - if (pathname) { - filename = path.join(outputPath, querystring.unescape(pathname)); + return; } - let fsStats; + // 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) + ); try { - fsStats = + // eslint-disable-next-line no-param-reassign + extra.stats = /** @type {import("fs").statSync} */ (context.outputFileSystem.statSync)(filename); } catch (_ignoreError) { // eslint-disable-next-line no-continue continue; } - if (fsStats.isFile()) { + if (extra.stats.isFile()) { foundFilename = filename; break; } else if ( - fsStats.isDirectory() && + extra.stats.isDirectory() && (typeof options.index === "undefined" || options.index) ) { const indexValue = @@ -123,15 +172,15 @@ function getFilenameFromUrl(context, url) { filename = path.join(filename, indexValue); try { - fsStats = + extra.stats = /** @type {import("fs").statSync} */ (context.outputFileSystem.statSync)(filename); } catch (__ignoreError) { // eslint-disable-next-line no-continue continue; } - if (fsStats.isFile()) { + if (extra.stats.isFile()) { foundFilename = filename; break;
types/utils/compatibleAPI.d.ts+48 −0 modified@@ -10,6 +10,28 @@ export type ExpectedResponse = { status: (status: number) => void; send: (data: any) => void; }; +/** + * send error options + */ +export type SendOptions< + Request_1 extends import("http").IncomingMessage, + Response_1 extends import("../index.js").ServerResponse +> = { + /** + * headers + */ + headers?: Record<string, number | string | string[] | undefined> | undefined; + /** + * modify response data callback + */ + modifyResponseData?: + | import("../index").ModifyResponseData<Request, Response> + | undefined; + /** + * modify response data callback + */ + outputFileSystem: import("../index").OutputFileSystem; +}; /** @typedef {import("../index.js").IncomingMessage} IncomingMessage */ /** @typedef {import("../index.js").ServerResponse} ServerResponse */ /** @@ -84,3 +106,29 @@ export function send< bufferOtStream: string | Buffer | import("fs").ReadStream, byteLength: number ): void; +/** + * @template {IncomingMessage} Request + * @template {ServerResponse} Response + * @typedef {Object} SendOptions send error options + * @property {Record<string, number | string | string[] | undefined>=} headers headers + * @property {import("../index").ModifyResponseData<Request, Response>=} modifyResponseData modify response data callback + * @property {import("../index").OutputFileSystem} outputFileSystem modify response data callback + */ +/** + * @template {IncomingMessage} Request + * @template {ServerResponse} Response + * @param {Request} req response + * @param {Response} res response + * @param {number} status status + * @param {Partial<SendOptions<Request, Response>>=} options options + * @returns {void} + */ +export function sendError< + Request_1 extends import("http").IncomingMessage, + Response_1 extends import("../index.js").ServerResponse +>( + req: Request_1, + res: Response_1, + status: number, + options?: Partial<SendOptions<Request_1, Response_1>> | undefined +): void;
types/utils/getFilenameFromUrl.d.ts+8 −2 modified@@ -5,17 +5,23 @@ export = getFilenameFromUrl; * @template {ServerResponse} Response * @param {import("../index.js").Context<Request, Response>} context * @param {string} url + * @param {Extra=} extra * @returns {string | undefined} */ declare function getFilenameFromUrl< Request_1 extends import("http").IncomingMessage, Response_1 extends import("../index.js").ServerResponse >( context: import("../index.js").Context<Request_1, Response_1>, - url: string + url: string, + extra?: Extra | undefined ): string | undefined; declare namespace getFilenameFromUrl { - export { IncomingMessage, ServerResponse }; + export { Extra, IncomingMessage, ServerResponse }; } +type Extra = { + stats?: import("fs").Stats | undefined; + errorCode?: number | undefined; +}; type IncomingMessage = import("../index.js").IncomingMessage; type ServerResponse = import("../index.js").ServerResponse;
e10008c762e4fix(security): do not allow to read files above (#1771)
6 files changed · +141 −21
.cspell.json+2 −1 modified@@ -18,7 +18,8 @@ "configurated", "mycustom", "commitlint", - "nosniff" + "nosniff", + "deoptimize" ], "ignorePaths": [ "CHANGELOG.md",
src/middleware.js+13 −0 modified@@ -80,6 +80,18 @@ function wrapper(context) { extra, ); + if (extra.errorCode) { + if (extra.errorCode === 403) { + context.logger.error(`Malicious path "${filename}".`); + } + + sendError(req, res, extra.errorCode, { + modifyResponseData: context.options.modifyResponseData, + }); + + return; + } + if (!filename) { await goNext(); @@ -164,6 +176,7 @@ function wrapper(context) { headers: { "Content-Range": res.getHeader("Content-Range"), }, + modifyResponseData: context.options.modifyResponseData, }); return;
src/utils/compatibleAPI.js+3 −1 modified@@ -177,6 +177,8 @@ function destroyStream(stream, suppress) { /** @type {Record<number, string>} */ const statuses = { + 400: "Bad Request", + 403: "Forbidden", 404: "Not Found", 416: "Range Not Satisfiable", 500: "Internal Server Error", @@ -213,7 +215,7 @@ function sendError(req, res, status, options) { // Send basic response setStatusCode(res, status); - setHeaderForResponse(res, "Content-Type", "text/html; charset=UTF-8"); + setHeaderForResponse(res, "Content-Type", "text/html; charset=utf-8"); setHeaderForResponse(res, "Content-Security-Policy", "default-src 'none'"); setHeaderForResponse(res, "X-Content-Type-Options", "nosniff");
src/utils/getFilenameFromUrl.js+41 −11 modified@@ -43,11 +43,28 @@ const mem = (fn, { cache = new Map() } = {}) => { }; const memoizedParse = mem(parse); +const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; + /** * @typedef {Object} Extra * @property {import("fs").Stats=} stats + * @property {number=} errorCode + */ + +/** + * decodeURIComponent. + * + * Allows V8 to only deoptimize this fn instead of all of send(). + * + * @param {string} input + * @returns {string} */ +function decode(input) { + return querystring.unescape(input); +} + +// TODO refactor me in the next major release, this function should return `{ filename, stats, error }` /** * @template {IncomingMessage} Request * @template {ServerResponse} Response @@ -85,22 +102,35 @@ function getFilenameFromUrl(context, url, extra = {}) { continue; } - if ( - urlObject.pathname && - urlObject.pathname.startsWith(publicPathObject.pathname) - ) { - filename = outputPath; + const pathname = decode(urlObject.pathname); + const publicPathPathname = decode(publicPathObject.pathname); + + if (pathname && pathname.startsWith(publicPathPathname)) { + // Null byte(s) + if (pathname.includes("\0")) { + // eslint-disable-next-line no-param-reassign + extra.errorCode = 400; + + return; + } + + // ".." is malicious + if (UP_PATH_REGEXP.test(path.normalize(`./${pathname}`))) { + // eslint-disable-next-line no-param-reassign + extra.errorCode = 403; + + return; + } // Strip the `pathname` property from the `publicPath` option from the start of requested url // `/complex/foo.js` => `foo.js` - const pathname = urlObject.pathname.slice( - publicPathObject.pathname.length, + // and add outputPath + // `foo.js` => `/home/user/my-project/dist/foo.js` + filename = path.join( + outputPath, + pathname.slice(publicPathPathname.length), ); - if (pathname) { - filename = path.join(outputPath, querystring.unescape(pathname)); - } - try { // eslint-disable-next-line no-param-reassign extra.stats =
test/middleware.test.js+81 −4 modified@@ -99,6 +99,10 @@ describe.each([ path.resolve(outputPath, "image.svg"), "svg image", ); + instance.context.outputFileSystem.writeFileSync( + path.resolve(outputPath, "image image.svg"), + "svg image", + ); instance.context.outputFileSystem.writeFileSync( path.resolve(outputPath, "byte-length.html"), "\u00bd + \u00bc = \u00be", @@ -183,6 +187,36 @@ describe.each([ expect(response.headers["content-type"]).toEqual("image/svg+xml"); }); + it('should return the "200" code for the "GET" request to the "image.svg" file with "/../"', async () => { + const fileData = instance.context.outputFileSystem.readFileSync( + path.resolve(outputPath, "image.svg"), + ); + + const response = await req.get("/public/../image.svg"); + + expect(response.statusCode).toEqual(200); + expect(response.headers["content-length"]).toEqual( + fileData.byteLength.toString(), + ); + expect(response.headers["content-type"]).toEqual("image/svg+xml"); + }); + + it('should return the "200" code for the "GET" request to the "image.svg" file with "/../../../"', async () => { + const fileData = instance.context.outputFileSystem.readFileSync( + path.resolve(outputPath, "image.svg"), + ); + + const response = await req.get( + "/public/assets/images/../../../image.svg", + ); + + expect(response.statusCode).toEqual(200); + expect(response.headers["content-length"]).toEqual( + fileData.byteLength.toString(), + ); + expect(response.headers["content-type"]).toEqual("image/svg+xml"); + }); + it('should return the "200" code for the "GET" request to the directory', async () => { const fileData = fs.readFileSync( path.resolve(__dirname, "./fixtures/index.html"), @@ -263,7 +297,7 @@ describe.each([ `bytes */${codeLength}`, ); expect(response.headers["content-type"]).toEqual( - "text/html; charset=UTF-8", + "text/html; charset=utf-8", ); expect(response.text).toEqual( `<!DOCTYPE html> @@ -447,6 +481,29 @@ describe.each([ false, ); }); + + it('should return the "200" code for the "GET" request to the "image image.svg" file', async () => { + const fileData = instance.context.outputFileSystem.readFileSync( + path.resolve(outputPath, "image image.svg"), + ); + + const response = await req.get("/image image.svg"); + + expect(response.statusCode).toEqual(200); + expect(response.headers["content-length"]).toEqual( + fileData.byteLength.toString(), + ); + expect(response.headers["content-type"]).toEqual("image/svg+xml"); + }); + + it('should return the "404" code for the "GET" request to the "%FF" file', async () => { + const response = await req.get("/%FF"); + + expect(response.statusCode).toEqual(404); + expect(response.headers["content-type"]).toEqual( + "text/html; charset=utf-8", + ); + }); }); describe('should not work with the broken "publicPath" option', () => { @@ -2032,7 +2089,7 @@ describe.each([ expect(response.statusCode).toEqual(500); expect(response.headers["content-type"]).toEqual( - "text/html; charset=UTF-8", + "text/html; charset=utf-8", ); expect(response.text).toEqual( "<!DOCTYPE html>\n" + @@ -2113,7 +2170,7 @@ describe.each([ expect(response.statusCode).toEqual(404); expect(response.headers["content-type"]).toEqual( - "text/html; charset=UTF-8", + "text/html; charset=utf-8", ); expect(response.text).toEqual( "<!DOCTYPE html>\n" + @@ -2575,6 +2632,7 @@ describe.each([ output: { filename: "bundle.js", path: path.resolve(__dirname, "./outputs/write-to-disk-true"), + publicPath: "/public/", }, }); @@ -2598,7 +2656,7 @@ describe.each([ it("should find the bundle file on disk", (done) => { request(app) - .get("/bundle.js") + .get("/public/bundle.js") .expect(200, (error) => { if (error) { return done(error); @@ -2632,6 +2690,25 @@ describe.each([ ); }); }); + + it("should not allow to get files above root", async () => { + const response = await req.get("/public/..%2f../middleware.test.js"); + + expect(response.statusCode).toEqual(403); + expect(response.headers["content-type"]).toEqual( + "text/html; charset=utf-8", + ); + expect(response.text).toEqual(`<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Error</title> +</head> +<body> +<pre>Forbidden</pre> +</body> +</html>`); + }); }); describe('should work with "true" value when the `output.clean` is `true`', () => {
types/utils/getFilenameFromUrl.d.ts+1 −4 modified@@ -1,9 +1,5 @@ /// <reference types="node" /> export = getFilenameFromUrl; -/** - * @typedef {Object} Extra - * @property {import("fs").Stats=} stats - */ /** * @template {IncomingMessage} Request * @template {ServerResponse} Response @@ -25,6 +21,7 @@ declare namespace getFilenameFromUrl { } type Extra = { stats?: import("fs").Stats | undefined; + errorCode?: number | undefined; }; type IncomingMessage = import("../index.js").IncomingMessage; type ServerResponse = import("../index.js").ServerResponse;
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
11- github.com/advisories/GHSA-wr3j-pwj9-hqq6ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-29180ghsaADVISORY
- github.com/webpack/webpack-dev-middleware/blob/7ed24e0b9f53ad1562343f9f517f0f0ad2a70377/src/utils/getFilenameFromUrl.jsghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-middleware/blob/7ed24e0b9f53ad1562343f9f517f0f0ad2a70377/src/utils/setupOutputFileSystem.jsghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-middleware/commit/189c4ac7d2344ec132a4689e74dc837ec5be0132ghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-middleware/commit/9670b3495da518fe667ff3428c5e4cb9f2f3d353ghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-middleware/commit/e10008c762e4d5821ed6990348dabf0d4d93a10eghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-middleware/releases/tag/v5.3.4ghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-middleware/releases/tag/v6.1.2ghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-middleware/releases/tag/v7.1.0ghsax_refsource_MISCWEB
- github.com/webpack/webpack-dev-middleware/security/advisories/GHSA-wr3j-pwj9-hqq6ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.