Skip to content

Commit a840f5a

Browse files
fix(security): do not allow to read files above
1 parent ab533de commit a840f5a

File tree

6 files changed

+107
-22
lines changed

6 files changed

+107
-22
lines changed

.cspell.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"configurated",
1919
"mycustom",
2020
"commitlint",
21-
"nosniff"
21+
"nosniff",
22+
"deoptimize"
2223
],
2324
"ignorePaths": [
2425
"CHANGELOG.md",

src/middleware.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ function wrapper(context) {
8080
extra,
8181
);
8282

83+
if (extra.errorCode) {
84+
if (extra.errorCode === 403) {
85+
context.logger.error(`Malicious path "${filename}".`);
86+
}
87+
88+
sendError(req, res, extra.errorCode, {
89+
modifyResponseData: context.options.modifyResponseData,
90+
});
91+
92+
return;
93+
}
94+
8395
if (!filename) {
8496
await goNext();
8597

@@ -164,6 +176,7 @@ function wrapper(context) {
164176
headers: {
165177
"Content-Range": res.getHeader("Content-Range"),
166178
},
179+
modifyResponseData: context.options.modifyResponseData,
167180
});
168181

169182
return;

src/utils/compatibleAPI.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ function destroyStream(stream, suppress) {
177177

178178
/** @type {Record<number, string>} */
179179
const statuses = {
180+
400: "Bad Request",
181+
403: "Forbidden",
180182
404: "Not Found",
181183
416: "Range Not Satisfiable",
182184
500: "Internal Server Error",
@@ -213,7 +215,7 @@ function sendError(req, res, status, options) {
213215

214216
// Send basic response
215217
setStatusCode(res, status);
216-
setHeaderForResponse(res, "Content-Type", "text/html; charset=UTF-8");
218+
setHeaderForResponse(res, "Content-Type", "text/html; charset=utf-8");
217219
setHeaderForResponse(res, "Content-Security-Policy", "default-src 'none'");
218220
setHeaderForResponse(res, "X-Content-Type-Options", "nosniff");
219221

src/utils/getFilenameFromUrl.js

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,28 @@ const mem = (fn, { cache = new Map() } = {}) => {
4343
};
4444
const memoizedParse = mem(parse);
4545

46+
const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
47+
4648
/**
4749
* @typedef {Object} Extra
4850
* @property {import("fs").Stats=} stats
51+
* @property {number=} errorCode
52+
*/
53+
54+
/**
55+
* decodeURIComponent.
56+
*
57+
* Allows V8 to only deoptimize this fn instead of all of send().
58+
*
59+
* @param {string} input
60+
* @returns {string}
4961
*/
5062

63+
function decode(input) {
64+
return querystring.unescape(input);
65+
}
66+
67+
// TODO refactor me in the next major release, this function should return `{ filename, stats, error }`
5168
/**
5269
* @template {IncomingMessage} Request
5370
* @template {ServerResponse} Response
@@ -85,22 +102,30 @@ function getFilenameFromUrl(context, url, extra = {}) {
85102
continue;
86103
}
87104

88-
if (
89-
urlObject.pathname &&
90-
urlObject.pathname.startsWith(publicPathObject.pathname)
91-
) {
92-
filename = outputPath;
105+
let pathname = decode(urlObject.pathname);
93106

94-
// Strip the `pathname` property from the `publicPath` option from the start of requested url
95-
// `/complex/foo.js` => `foo.js`
96-
const pathname = urlObject.pathname.slice(
97-
publicPathObject.pathname.length,
98-
);
107+
// Null byte(s)
108+
if (pathname.includes("\0")) {
109+
// eslint-disable-next-line no-param-reassign
110+
extra.errorCode = 400;
111+
112+
return;
113+
}
114+
115+
if (pathname && pathname.startsWith(publicPathObject.pathname)) {
116+
// ".." is malicious
117+
if (UP_PATH_REGEXP.test(path.normalize(`.${path.sep}${pathname}`))) {
118+
// eslint-disable-next-line no-param-reassign
119+
extra.errorCode = 403;
99120

100-
if (pathname) {
101-
filename = path.join(outputPath, querystring.unescape(pathname));
121+
return;
102122
}
103123

124+
// Strip the `pathname` property from the `publicPath` option from the start of requested url
125+
// `/complex/foo.js` => `foo.js`
126+
pathname = pathname.slice(publicPathObject.pathname.length);
127+
filename = path.join(outputPath, pathname);
128+
104129
try {
105130
// eslint-disable-next-line no-param-reassign
106131
extra.stats =

test/middleware.test.js

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ describe.each([
9999
path.resolve(outputPath, "image.svg"),
100100
"svg image",
101101
);
102+
instance.context.outputFileSystem.writeFileSync(
103+
path.resolve(outputPath, "image image.svg"),
104+
"svg image",
105+
);
102106
instance.context.outputFileSystem.writeFileSync(
103107
path.resolve(outputPath, "byte-length.html"),
104108
"\u00bd + \u00bc = \u00be",
@@ -263,7 +267,7 @@ describe.each([
263267
`bytes */${codeLength}`,
264268
);
265269
expect(response.headers["content-type"]).toEqual(
266-
"text/html; charset=UTF-8",
270+
"text/html; charset=utf-8",
267271
);
268272
expect(response.text).toEqual(
269273
`<!DOCTYPE html>
@@ -447,6 +451,29 @@ describe.each([
447451
false,
448452
);
449453
});
454+
455+
it('should return the "200" code for the "GET" request to the "image image.svg" file', async () => {
456+
const fileData = instance.context.outputFileSystem.readFileSync(
457+
path.resolve(outputPath, "image image.svg"),
458+
);
459+
460+
const response = await req.get("/image image.svg");
461+
462+
expect(response.statusCode).toEqual(200);
463+
expect(response.headers["content-length"]).toEqual(
464+
fileData.byteLength.toString(),
465+
);
466+
expect(response.headers["content-type"]).toEqual("image/svg+xml");
467+
});
468+
469+
it('should return the "404" code for the "GET" request to the "%FF" file', async () => {
470+
const response = await req.get("/%FF");
471+
472+
expect(response.statusCode).toEqual(404);
473+
expect(response.headers["content-type"]).toEqual(
474+
"text/html; charset=utf-8",
475+
);
476+
});
450477
});
451478

452479
describe('should not work with the broken "publicPath" option', () => {
@@ -2032,7 +2059,7 @@ describe.each([
20322059

20332060
expect(response.statusCode).toEqual(500);
20342061
expect(response.headers["content-type"]).toEqual(
2035-
"text/html; charset=UTF-8",
2062+
"text/html; charset=utf-8",
20362063
);
20372064
expect(response.text).toEqual(
20382065
"<!DOCTYPE html>\n" +
@@ -2113,7 +2140,7 @@ describe.each([
21132140

21142141
expect(response.statusCode).toEqual(404);
21152142
expect(response.headers["content-type"]).toEqual(
2116-
"text/html; charset=UTF-8",
2143+
"text/html; charset=utf-8",
21172144
);
21182145
expect(response.text).toEqual(
21192146
"<!DOCTYPE html>\n" +
@@ -2575,6 +2602,7 @@ describe.each([
25752602
output: {
25762603
filename: "bundle.js",
25772604
path: path.resolve(__dirname, "./outputs/write-to-disk-true"),
2605+
publicPath: "/public/",
25782606
},
25792607
});
25802608

@@ -2598,7 +2626,7 @@ describe.each([
25982626

25992627
it("should find the bundle file on disk", (done) => {
26002628
request(app)
2601-
.get("/bundle.js")
2629+
.get("/public/bundle.js")
26022630
.expect(200, (error) => {
26032631
if (error) {
26042632
return done(error);
@@ -2632,6 +2660,25 @@ describe.each([
26322660
);
26332661
});
26342662
});
2663+
2664+
it("should not allow to get files above root", async () => {
2665+
const response = await req.get("/public/..%2f../middleware.test.js");
2666+
2667+
expect(response.statusCode).toEqual(403);
2668+
expect(response.headers["content-type"]).toEqual(
2669+
"text/html; charset=utf-8",
2670+
);
2671+
expect(response.text).toEqual(`<!DOCTYPE html>
2672+
<html lang="en">
2673+
<head>
2674+
<meta charset="utf-8">
2675+
<title>Error</title>
2676+
</head>
2677+
<body>
2678+
<pre>Forbidden</pre>
2679+
</body>
2680+
</html>`);
2681+
});
26352682
});
26362683

26372684
describe('should work with "true" value when the `output.clean` is `true`', () => {

types/utils/getFilenameFromUrl.d.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
/// <reference types="node" />
22
export = getFilenameFromUrl;
3-
/**
4-
* @typedef {Object} Extra
5-
* @property {import("fs").Stats=} stats
6-
*/
73
/**
84
* @template {IncomingMessage} Request
95
* @template {ServerResponse} Response
@@ -25,6 +21,7 @@ declare namespace getFilenameFromUrl {
2521
}
2622
type Extra = {
2723
stats?: import("fs").Stats | undefined;
24+
errorCode?: number | undefined;
2825
};
2926
type IncomingMessage = import("../index.js").IncomingMessage;
3027
type ServerResponse = import("../index.js").ServerResponse;

0 commit comments

Comments
 (0)