Skip to content

Commit e10008c

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

File tree

6 files changed

+141
-21
lines changed

6 files changed

+141
-21
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: 41 additions & 11 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,35 @@ 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+
const pathname = decode(urlObject.pathname);
106+
const publicPathPathname = decode(publicPathObject.pathname);
107+
108+
if (pathname && pathname.startsWith(publicPathPathname)) {
109+
// Null byte(s)
110+
if (pathname.includes("\0")) {
111+
// eslint-disable-next-line no-param-reassign
112+
extra.errorCode = 400;
113+
114+
return;
115+
}
116+
117+
// ".." is malicious
118+
if (UP_PATH_REGEXP.test(path.normalize(`./${pathname}`))) {
119+
// eslint-disable-next-line no-param-reassign
120+
extra.errorCode = 403;
121+
122+
return;
123+
}
93124

94125
// Strip the `pathname` property from the `publicPath` option from the start of requested url
95126
// `/complex/foo.js` => `foo.js`
96-
const pathname = urlObject.pathname.slice(
97-
publicPathObject.pathname.length,
127+
// and add outputPath
128+
// `foo.js` => `/home/user/my-project/dist/foo.js`
129+
filename = path.join(
130+
outputPath,
131+
pathname.slice(publicPathPathname.length),
98132
);
99133

100-
if (pathname) {
101-
filename = path.join(outputPath, querystring.unescape(pathname));
102-
}
103-
104134
try {
105135
// eslint-disable-next-line no-param-reassign
106136
extra.stats =

test/middleware.test.js

Lines changed: 81 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",
@@ -183,6 +187,36 @@ describe.each([
183187
expect(response.headers["content-type"]).toEqual("image/svg+xml");
184188
});
185189

190+
it('should return the "200" code for the "GET" request to the "image.svg" file with "/../"', async () => {
191+
const fileData = instance.context.outputFileSystem.readFileSync(
192+
path.resolve(outputPath, "image.svg"),
193+
);
194+
195+
const response = await req.get("/public/../image.svg");
196+
197+
expect(response.statusCode).toEqual(200);
198+
expect(response.headers["content-length"]).toEqual(
199+
fileData.byteLength.toString(),
200+
);
201+
expect(response.headers["content-type"]).toEqual("image/svg+xml");
202+
});
203+
204+
it('should return the "200" code for the "GET" request to the "image.svg" file with "/../../../"', async () => {
205+
const fileData = instance.context.outputFileSystem.readFileSync(
206+
path.resolve(outputPath, "image.svg"),
207+
);
208+
209+
const response = await req.get(
210+
"/public/assets/images/../../../image.svg",
211+
);
212+
213+
expect(response.statusCode).toEqual(200);
214+
expect(response.headers["content-length"]).toEqual(
215+
fileData.byteLength.toString(),
216+
);
217+
expect(response.headers["content-type"]).toEqual("image/svg+xml");
218+
});
219+
186220
it('should return the "200" code for the "GET" request to the directory', async () => {
187221
const fileData = fs.readFileSync(
188222
path.resolve(__dirname, "./fixtures/index.html"),
@@ -263,7 +297,7 @@ describe.each([
263297
`bytes */${codeLength}`,
264298
);
265299
expect(response.headers["content-type"]).toEqual(
266-
"text/html; charset=UTF-8",
300+
"text/html; charset=utf-8",
267301
);
268302
expect(response.text).toEqual(
269303
`<!DOCTYPE html>
@@ -447,6 +481,29 @@ describe.each([
447481
false,
448482
);
449483
});
484+
485+
it('should return the "200" code for the "GET" request to the "image image.svg" file', async () => {
486+
const fileData = instance.context.outputFileSystem.readFileSync(
487+
path.resolve(outputPath, "image image.svg"),
488+
);
489+
490+
const response = await req.get("/image image.svg");
491+
492+
expect(response.statusCode).toEqual(200);
493+
expect(response.headers["content-length"]).toEqual(
494+
fileData.byteLength.toString(),
495+
);
496+
expect(response.headers["content-type"]).toEqual("image/svg+xml");
497+
});
498+
499+
it('should return the "404" code for the "GET" request to the "%FF" file', async () => {
500+
const response = await req.get("/%FF");
501+
502+
expect(response.statusCode).toEqual(404);
503+
expect(response.headers["content-type"]).toEqual(
504+
"text/html; charset=utf-8",
505+
);
506+
});
450507
});
451508

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

20332090
expect(response.statusCode).toEqual(500);
20342091
expect(response.headers["content-type"]).toEqual(
2035-
"text/html; charset=UTF-8",
2092+
"text/html; charset=utf-8",
20362093
);
20372094
expect(response.text).toEqual(
20382095
"<!DOCTYPE html>\n" +
@@ -2113,7 +2170,7 @@ describe.each([
21132170

21142171
expect(response.statusCode).toEqual(404);
21152172
expect(response.headers["content-type"]).toEqual(
2116-
"text/html; charset=UTF-8",
2173+
"text/html; charset=utf-8",
21172174
);
21182175
expect(response.text).toEqual(
21192176
"<!DOCTYPE html>\n" +
@@ -2575,6 +2632,7 @@ describe.each([
25752632
output: {
25762633
filename: "bundle.js",
25772634
path: path.resolve(__dirname, "./outputs/write-to-disk-true"),
2635+
publicPath: "/public/",
25782636
},
25792637
});
25802638

@@ -2598,7 +2656,7 @@ describe.each([
25982656

25992657
it("should find the bundle file on disk", (done) => {
26002658
request(app)
2601-
.get("/bundle.js")
2659+
.get("/public/bundle.js")
26022660
.expect(200, (error) => {
26032661
if (error) {
26042662
return done(error);
@@ -2632,6 +2690,25 @@ describe.each([
26322690
);
26332691
});
26342692
});
2693+
2694+
it("should not allow to get files above root", async () => {
2695+
const response = await req.get("/public/..%2f../middleware.test.js");
2696+
2697+
expect(response.statusCode).toEqual(403);
2698+
expect(response.headers["content-type"]).toEqual(
2699+
"text/html; charset=utf-8",
2700+
);
2701+
expect(response.text).toEqual(`<!DOCTYPE html>
2702+
<html lang="en">
2703+
<head>
2704+
<meta charset="utf-8">
2705+
<title>Error</title>
2706+
</head>
2707+
<body>
2708+
<pre>Forbidden</pre>
2709+
</body>
2710+
</html>`);
2711+
});
26352712
});
26362713

26372714
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)