Skip to content

Commit ec0fb5a

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

File tree

6 files changed

+115
-19
lines changed

6 files changed

+115
-19
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: 2 additions & 0 deletions
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",

src/utils/getFilenameFromUrl.js

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
const path = require("path");
22
const { parse } = require("url");
3-
const querystring = require("querystring");
43

54
const getPaths = require("./getPaths");
65

@@ -43,11 +42,32 @@ const mem = (fn, { cache = new Map() } = {}) => {
4342
};
4443
const memoizedParse = mem(parse);
4544

45+
const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
46+
4647
/**
4748
* @typedef {Object} Extra
4849
* @property {import("fs").Stats=} stats
50+
* @property {number=} errorCode
4951
*/
5052

53+
/**
54+
* decodeURIComponent.
55+
*
56+
* Allows V8 to only deoptimize this fn instead of all of send().
57+
*
58+
* @param {string} path
59+
* @returns {string | -1}
60+
*/
61+
62+
function decode(path) {
63+
try {
64+
return decodeURIComponent(path);
65+
} catch (err) {
66+
return -1;
67+
}
68+
}
69+
70+
// TODO refactor me in the next major release, this function should return `{ filename, stats, error }`
5171
/**
5272
* @template {IncomingMessage} Request
5373
* @template {ServerResponse} Response
@@ -85,21 +105,37 @@ function getFilenameFromUrl(context, url, extra = {}) {
85105
continue;
86106
}
87107

88-
if (
89-
urlObject.pathname &&
90-
urlObject.pathname.startsWith(publicPathObject.pathname)
91-
) {
92-
filename = outputPath;
108+
let pathname = decode(urlObject.pathname);
109+
110+
// Can't be decoded
111+
if (pathname === -1) {
112+
// eslint-disable-next-line no-param-reassign
113+
extra.errorCode = 400;
114+
115+
return;
116+
}
117+
118+
// Null byte(s)
119+
if (pathname.includes("\0")) {
120+
// eslint-disable-next-line no-param-reassign
121+
extra.errorCode = 400;
122+
123+
return;
124+
}
125+
126+
// ".." is malicious
127+
if (UP_PATH_REGEXP.test(pathname)) {
128+
// eslint-disable-next-line no-param-reassign
129+
extra.errorCode = 403;
93130

131+
return;
132+
}
133+
134+
if (pathname && pathname.startsWith(publicPathObject.pathname)) {
94135
// Strip the `pathname` property from the `publicPath` option from the start of requested url
95136
// `/complex/foo.js` => `foo.js`
96-
const pathname = urlObject.pathname.slice(
97-
publicPathObject.pathname.length,
98-
);
99-
100-
if (pathname) {
101-
filename = path.join(outputPath, querystring.unescape(pathname));
102-
}
137+
pathname = pathname.slice(publicPathObject.pathname.length);
138+
filename = path.join(outputPath, pathname);
103139

104140
try {
105141
// eslint-disable-next-line no-param-reassign

test/middleware.test.js

Lines changed: 48 additions & 1 deletion
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",
@@ -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 "400" code for the "GET" request to the "%FF" file', async () => {
470+
const response = await req.get("/%FF");
471+
472+
expect(response.statusCode).toEqual(400);
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', () => {
@@ -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)