VYPR
High severityNVD Advisory· Published Mar 21, 2024· Updated Aug 2, 2024

webpack-dev-middleware Path Traversal vulnerability

CVE-2024-29180

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.

PackageAffected versionsPatched versions
webpack-dev-middlewarenpm
>= 7.0.0, < 7.1.07.1.0
webpack-dev-middlewarenpm
>= 6.0.0, < 6.1.26.1.2
webpack-dev-middlewarenpm
< 5.3.45.3.4

Affected products

1

Patches

3
189c4ac7d234

fix(security): do not allow to read files above (#1779)

https://github.com/webpack/webpack-dev-middlewareAlexander AkaitMar 20, 2024via ghsa
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 = "&quot;";
    +        break;
    +      // &
    +      case 38:
    +        escape = "&amp;";
    +        break;
    +      // '
    +      case 39:
    +        escape = "&#39;";
    +        break;
    +      // <
    +      case 60:
    +        escape = "&lt;";
    +        break;
    +      // >
    +      case 62:
    +        escape = "&gt;";
    +        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;
    
9670b3495da5

fix(security): do not allow to read files above (#1778)

https://github.com/webpack/webpack-dev-middlewareAlexander AkaitMar 20, 2024via ghsa
6 files changed · +14832 55
  • package-lock.json+14535 30 modified
  • src/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 = "&quot;";
    +        break;
    +      // &
    +      case 38:
    +        escape = "&amp;";
    +        break;
    +      // '
    +      case 39:
    +        escape = "&#39;";
    +        break;
    +      // <
    +      case 60:
    +        escape = "&lt;";
    +        break;
    +      // >
    +      case 62:
    +        escape = "&gt;";
    +        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;
    
e10008c762e4

fix(security): do not allow to read files above (#1771)

https://github.com/webpack/webpack-dev-middlewareAlexander AkaitMar 19, 2024via ghsa
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

News mentions

0

No linked articles in our index yet.