Skip to content

Commit

Permalink
fix(security): do not allow to read files above (#1779)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexander-akait authored Mar 20, 2024
1 parent f3c62b8 commit 189c4ac
Show file tree
Hide file tree
Showing 5 changed files with 225 additions and 26 deletions.
16 changes: 15 additions & 1 deletion src/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
setHeaderForResponse,
setStatusCode,
send,
sendError,
} = require("./utils/compatibleAPI");
const ready = require("./utils/ready");

Expand Down Expand Up @@ -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) {
Expand All @@ -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") {
Expand Down
116 changes: 116 additions & 0 deletions src/utils/compatibleAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
97 changes: 74 additions & 23 deletions src/utils/getFilenameFromUrl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -64,7 +100,9 @@ function getFilenameFromUrl(context, url) {
}

for (const { publicPath, outputPath } of paths) {
/** @type {string | undefined} */
let filename;
/** @type {URL} */
let publicPathObject;

try {
Expand All @@ -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 =
Expand All @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions types/utils/compatibleAPI.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
10 changes: 8 additions & 2 deletions types/utils/getFilenameFromUrl.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

0 comments on commit 189c4ac

Please sign in to comment.