Skip to content

Commit 501bfd0

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

File tree

6 files changed

+115
-18
lines changed

6 files changed

+115
-18
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 & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,32 @@ 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
4952
*/
5053

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 | -1}
61+
*/
62+
63+
function decode(input) {
64+
try {
65+
return querystring.unescape(input);
66+
} catch (err) {
67+
return -1;
68+
}
69+
}
70+
71+
// TODO refactor me in the next major release, this function should return `{ filename, stats, error }`
5172
/**
5273
* @template {IncomingMessage} Request
5374
* @template {ServerResponse} Response
@@ -85,21 +106,37 @@ function getFilenameFromUrl(context, url, extra = {}) {
85106
continue;
86107
}
87108

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

132+
return;
133+
}
134+
135+
if (pathname && pathname.startsWith(publicPathObject.pathname)) {
94136
// Strip the `pathname` property from the `publicPath` option from the start of requested url
95137
// `/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-
}
138+
pathname = pathname.slice(publicPathObject.pathname.length);
139+
filename = path.join(outputPath, pathname);
103140

104141
try {
105142
// 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)