Skip to content

Commit 196e05f

Browse files
authored
feat: Add new Parse Server option fileUpload.fileExtensions to restrict file upload by file extension; this fixes a security vulnerability in which a phishing attack could be performed using an uploaded HTML file; by default the new option only allows file extensions matching the regex pattern ^[^hH][^tT][^mM][^lL]?$, which excludes HTML files; this fix is released as a patch version given the severity of this vulnerability, however, if your app currently depends on uploading files with HTML file extensions then this may be a breaking change and you could allow HTML file upload by setting the option to ['.*'] (#8537)
1 parent e9ae435 commit 196e05f

File tree

6 files changed

+211
-26
lines changed

6 files changed

+211
-26
lines changed

spec/ParseFile.spec.js

Lines changed: 163 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,14 @@ describe('Parse.File testing', () => {
3737
});
3838
});
3939

40-
it('works with _ContentType', done => {
41-
request({
40+
it('works with _ContentType', async () => {
41+
await reconfigureServer({
42+
fileUpload: {
43+
enableForPublic: true,
44+
fileExtensions: ['*'],
45+
},
46+
});
47+
let response = await request({
4248
method: 'POST',
4349
url: 'http://localhost:8378/1/files/file',
4450
body: JSON.stringify({
@@ -47,21 +53,18 @@ describe('Parse.File testing', () => {
4753
_ContentType: 'text/html',
4854
base64: 'PGh0bWw+PC9odG1sPgo=',
4955
}),
50-
}).then(response => {
51-
const b = response.data;
52-
expect(b.name).toMatch(/_file.html/);
53-
expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/);
54-
request({ url: b.url }).then(response => {
55-
const body = response.text;
56-
try {
57-
expect(response.headers['content-type']).toMatch('^text/html');
58-
expect(body).toEqual('<html></html>\n');
59-
} catch (e) {
60-
jfail(e);
61-
}
62-
done();
63-
});
6456
});
57+
const b = response.data;
58+
expect(b.name).toMatch(/_file.html/);
59+
expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/);
60+
response = await request({ url: b.url });
61+
const body = response.text;
62+
try {
63+
expect(response.headers['content-type']).toMatch('^text/html');
64+
expect(body).toEqual('<html></html>\n');
65+
} catch (e) {
66+
jfail(e);
67+
}
6568
});
6669

6770
it('works without Content-Type', done => {
@@ -351,25 +354,29 @@ describe('Parse.File testing', () => {
351354
ok(object.toJSON().file.url);
352355
});
353356

354-
it('content-type used with no extension', done => {
357+
it('content-type used with no extension', async () => {
358+
await reconfigureServer({
359+
fileUpload: {
360+
enableForPublic: true,
361+
fileExtensions: ['*'],
362+
},
363+
});
355364
const headers = {
356365
'Content-Type': 'text/html',
357366
'X-Parse-Application-Id': 'test',
358367
'X-Parse-REST-API-Key': 'rest',
359368
};
360-
request({
369+
let response = await request({
361370
method: 'POST',
362371
headers: headers,
363372
url: 'http://localhost:8378/1/files/file',
364373
body: 'fee fi fo',
365-
}).then(response => {
366-
const b = response.data;
367-
expect(b.name).toMatch(/\.html$/);
368-
request({ url: b.url }).then(response => {
369-
expect(response.headers['content-type']).toMatch(/^text\/html/);
370-
done();
371-
});
372374
});
375+
376+
const b = response.data;
377+
expect(b.name).toMatch(/\.html$/);
378+
response = await request({ url: b.url });
379+
expect(response.headers['content-type']).toMatch(/^text\/html/);
373380
});
374381

375382
it('filename is url encoded', done => {
@@ -1298,6 +1305,137 @@ describe('Parse.File testing', () => {
12981305
await expectAsync(reconfigureServer({ fileUpload: { [key]: value } })).toBeResolved();
12991306
}
13001307
}
1308+
await expectAsync(
1309+
reconfigureServer({
1310+
fileUpload: {
1311+
fileExtensions: 1,
1312+
},
1313+
})
1314+
).toBeRejectedWith('fileUpload.fileExtensions must be an array.');
1315+
});
1316+
});
1317+
1318+
describe('fileExtensions', () => {
1319+
it('works with _ContentType', async () => {
1320+
await reconfigureServer({
1321+
silent: false,
1322+
fileUpload: {
1323+
enableForPublic: true,
1324+
fileExtensions: ['png'],
1325+
},
1326+
});
1327+
await expectAsync(
1328+
request({
1329+
method: 'POST',
1330+
url: 'http://localhost:8378/1/files/file',
1331+
body: JSON.stringify({
1332+
_ApplicationId: 'test',
1333+
_JavaScriptKey: 'test',
1334+
_ContentType: 'text/html',
1335+
base64: 'PGh0bWw+PC9odG1sPgo=',
1336+
}),
1337+
}).catch(e => {
1338+
throw new Error(e.data.error);
1339+
})
1340+
).toBeRejectedWith(
1341+
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`)
1342+
);
1343+
});
1344+
1345+
it('works without Content-Type', async () => {
1346+
await reconfigureServer({
1347+
fileUpload: {
1348+
enableForPublic: true,
1349+
},
1350+
});
1351+
const headers = {
1352+
'X-Parse-Application-Id': 'test',
1353+
'X-Parse-REST-API-Key': 'rest',
1354+
};
1355+
await expectAsync(
1356+
request({
1357+
method: 'POST',
1358+
headers: headers,
1359+
url: 'http://localhost:8378/1/files/file.html',
1360+
body: '<html></html>\n',
1361+
}).catch(e => {
1362+
throw new Error(e.data.error);
1363+
})
1364+
).toBeRejectedWith(
1365+
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`)
1366+
);
1367+
});
1368+
1369+
it('works with array', async () => {
1370+
await reconfigureServer({
1371+
fileUpload: {
1372+
enableForPublic: true,
1373+
fileExtensions: ['jpg'],
1374+
},
1375+
});
1376+
await expectAsync(
1377+
request({
1378+
method: 'POST',
1379+
url: 'http://localhost:8378/1/files/file',
1380+
body: JSON.stringify({
1381+
_ApplicationId: 'test',
1382+
_JavaScriptKey: 'test',
1383+
_ContentType: 'text/html',
1384+
base64: 'PGh0bWw+PC9odG1sPgo=',
1385+
}),
1386+
}).catch(e => {
1387+
throw new Error(e.data.error);
1388+
})
1389+
).toBeRejectedWith(
1390+
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`)
1391+
);
1392+
});
1393+
1394+
it('works with array without Content-Type', async () => {
1395+
await reconfigureServer({
1396+
fileUpload: {
1397+
enableForPublic: true,
1398+
fileExtensions: ['jpg'],
1399+
},
1400+
});
1401+
const headers = {
1402+
'X-Parse-Application-Id': 'test',
1403+
'X-Parse-REST-API-Key': 'rest',
1404+
};
1405+
await expectAsync(
1406+
request({
1407+
method: 'POST',
1408+
headers: headers,
1409+
url: 'http://localhost:8378/1/files/file.html',
1410+
body: '<html></html>\n',
1411+
}).catch(e => {
1412+
throw new Error(e.data.error);
1413+
})
1414+
).toBeRejectedWith(
1415+
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`)
1416+
);
1417+
});
1418+
1419+
it('works with array with correct file type', async () => {
1420+
await reconfigureServer({
1421+
fileUpload: {
1422+
enableForPublic: true,
1423+
fileExtensions: ['html'],
1424+
},
1425+
});
1426+
const response = await request({
1427+
method: 'POST',
1428+
url: 'http://localhost:8378/1/files/file',
1429+
body: JSON.stringify({
1430+
_ApplicationId: 'test',
1431+
_JavaScriptKey: 'test',
1432+
_ContentType: 'text/html',
1433+
base64: 'PGh0bWw+PC9odG1sPgo=',
1434+
}),
1435+
});
1436+
const b = response.data;
1437+
expect(b.name).toMatch(/_file.html$/);
1438+
expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/);
13011439
});
13021440
});
13031441
});

src/Config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,11 @@ export class Config {
424424
} else if (typeof fileUpload.enableForAuthenticatedUser !== 'boolean') {
425425
throw 'fileUpload.enableForAuthenticatedUser must be a boolean value.';
426426
}
427+
if (fileUpload.fileExtensions === undefined) {
428+
fileUpload.fileExtensions = FileUploadOptions.fileExtensions.default;
429+
} else if (!Array.isArray(fileUpload.fileExtensions)) {
430+
throw 'fileUpload.fileExtensions must be an array.';
431+
}
427432
}
428433

429434
static validateMasterKeyIps(masterKeyIps) {

src/Options/Definitions.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ This code has been generated by resources/buildConfigDefinitions.js
44
Do not edit manually, but update Options/index.js
55
*/
66
var parsers = require('./parsers');
7-
87
module.exports.SchemaOptions = {
98
afterMigration: {
109
env: 'PARSE_SERVER_SCHEMA_AFTER_MIGRATION',
@@ -880,6 +879,13 @@ module.exports.FileUploadOptions = {
880879
action: parsers.booleanParser,
881880
default: false,
882881
},
882+
fileExtensions: {
883+
env: 'PARSE_SERVER_FILE_UPLOAD_FILE_EXTENSIONS',
884+
help:
885+
"Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.<br><br>It is recommended to restrict the file upload extensions as much as possible. HTML files are especially problematic as they may be used by an attacker who uploads a HTML form to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.<br><br>Defaults to `^[^hH][^tT][^mM][^lL]?$` which allows any file extension except HTML files.",
886+
action: parsers.arrayParser,
887+
default: ['^[^hH][^tT][^mM][^lL]?$'],
888+
},
883889
};
884890
module.exports.DatabaseOptions = {
885891
enableSchemaHooks: {

src/Options/docs.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@
204204
* @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users.
205205
* @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users.
206206
* @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication.
207+
* @property {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.<br><br>It is recommended to restrict the file upload extensions as much as possible. HTML files are especially problematic as they may be used by an attacker who uploads a HTML form to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.<br><br>Defaults to `^[^hH][^tT][^mM][^lL]?$` which allows any file extension except HTML files.
207208
*/
208209

209210
/**

src/Options/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,9 @@ export interface PasswordPolicyOptions {
496496
}
497497

498498
export interface FileUploadOptions {
499+
/* Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.<br><br>It is recommended to restrict the file upload extensions as much as possible. HTML files are especially problematic as they may be used by an attacker who uploads a HTML form to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.<br><br>Defaults to `^[^hH][^tT][^mM][^lL]?$` which allows any file extension except HTML files.
500+
:DEFAULT: ["^[^hH][^tT][^mM][^lL]?$"] */
501+
fileExtensions: ?(string[]);
499502
/* Is true if file upload should be allowed for anonymous users.
500503
:DEFAULT: false */
501504
enableForAnonymousUser: ?boolean;

src/Routers/FilesRouter.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,38 @@ export class FilesRouter {
138138
return;
139139
}
140140

141+
const fileExtensions = config.fileUpload?.fileExtensions;
142+
if (!isMaster && fileExtensions) {
143+
const isValidExtension = extension => {
144+
return fileExtensions.some(ext => {
145+
if (ext === '*') {
146+
return true;
147+
}
148+
const regex = new RegExp(fileExtensions);
149+
if (regex.test(extension)) {
150+
return true;
151+
}
152+
});
153+
};
154+
let extension = contentType;
155+
if (filename && filename.includes('.')) {
156+
extension = filename.split('.')[1];
157+
} else if (contentType && contentType.includes('/')) {
158+
extension = contentType.split('/')[1];
159+
}
160+
extension = extension.split(' ').join('');
161+
162+
if (!isValidExtension(extension)) {
163+
next(
164+
new Parse.Error(
165+
Parse.Error.FILE_SAVE_ERROR,
166+
`File upload of extension ${extension} is disabled.`
167+
)
168+
);
169+
return;
170+
}
171+
}
172+
141173
const base64 = req.body.toString('base64');
142174
const file = new Parse.File(filename, { base64 }, contentType);
143175
const { metadata = {}, tags = {} } = req.fileData || {};

0 commit comments

Comments
 (0)