diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index ed21304d39..eeab537008 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -37,8 +37,14 @@ describe('Parse.File testing', () => {
});
});
- it('works with _ContentType', done => {
- request({
+ it('works with _ContentType', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ fileExtensions: ['*'],
+ },
+ });
+ let response = await request({
method: 'POST',
url: 'http://localhost:8378/1/files/file',
body: JSON.stringify({
@@ -47,21 +53,18 @@ describe('Parse.File testing', () => {
_ContentType: 'text/html',
base64: 'PGh0bWw+PC9odG1sPgo=',
}),
- }).then(response => {
- const b = response.data;
- expect(b.name).toMatch(/_file.html/);
- expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/);
- request({ url: b.url }).then(response => {
- const body = response.text;
- try {
- expect(response.headers['content-type']).toMatch('^text/html');
- expect(body).toEqual('\n');
- } catch (e) {
- jfail(e);
- }
- done();
- });
});
+ const b = response.data;
+ expect(b.name).toMatch(/_file.html/);
+ expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/);
+ response = await request({ url: b.url });
+ const body = response.text;
+ try {
+ expect(response.headers['content-type']).toMatch('^text/html');
+ expect(body).toEqual('\n');
+ } catch (e) {
+ jfail(e);
+ }
});
it('works without Content-Type', done => {
@@ -351,25 +354,28 @@ describe('Parse.File testing', () => {
ok(object.toJSON().file.url);
});
- it('content-type used with no extension', done => {
+ it('content-type used with no extension', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ fileExtensions: ['*'],
+ },
+ });
const headers = {
'Content-Type': 'text/html',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
- request({
+ let response = await request({
method: 'POST',
headers: headers,
url: 'http://localhost:8378/1/files/file',
body: 'fee fi fo',
- }).then(response => {
- const b = response.data;
- expect(b.name).toMatch(/\.html$/);
- request({ url: b.url }).then(response => {
- expect(response.headers['content-type']).toMatch(/^text\/html/);
- done();
- });
});
+ const b = response.data;
+ expect(b.name).toMatch(/\.html$/);
+ response = await request({ url: b.url });
+ expect(response.headers['content-type']).toMatch(/^text\/html/);
});
it('filename is url encoded', done => {
@@ -1298,6 +1304,136 @@ describe('Parse.File testing', () => {
await expectAsync(reconfigureServer({ fileUpload: { [key]: value } })).toBeResolved();
}
}
+ await expectAsync(
+ reconfigureServer({
+ fileUpload: {
+ fileExtensions: 1,
+ },
+ })
+ ).toBeRejectedWith('fileUpload.fileExtensions must be an array.');
+ });
+ });
+
+ describe('fileExtensions', () => {
+ it('works with _ContentType', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ fileExtensions: ['png'],
+ },
+ });
+ await expectAsync(
+ request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/file',
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'text/html',
+ base64: 'PGh0bWw+PC9odG1sPgo=',
+ }),
+ }).catch(e => {
+ throw new Error(e.data.error);
+ })
+ ).toBeRejectedWith(
+ new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`)
+ );
+ });
+
+ it('works without Content-Type', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ },
+ });
+ const headers = {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ };
+ await expectAsync(
+ request({
+ method: 'POST',
+ headers: headers,
+ url: 'http://localhost:8378/1/files/file.html',
+ body: '\n',
+ }).catch(e => {
+ throw new Error(e.data.error);
+ })
+ ).toBeRejectedWith(
+ new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`)
+ );
+ });
+
+ it('works with array', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ fileExtensions: ['jpg'],
+ },
+ });
+ await expectAsync(
+ request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/file',
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'text/html',
+ base64: 'PGh0bWw+PC9odG1sPgo=',
+ }),
+ }).catch(e => {
+ throw new Error(e.data.error);
+ })
+ ).toBeRejectedWith(
+ new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`)
+ );
+ });
+
+ it('works with array without Content-Type', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ fileExtensions: ['jpg'],
+ },
+ });
+ const headers = {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ };
+ await expectAsync(
+ request({
+ method: 'POST',
+ headers: headers,
+ url: 'http://localhost:8378/1/files/file.html',
+ body: '\n',
+ }).catch(e => {
+ throw new Error(e.data.error);
+ })
+ ).toBeRejectedWith(
+ new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`)
+ );
+ });
+
+ it('works with array with correct file type', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ fileExtensions: ['html'],
+ },
+ });
+ const response = await request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/file',
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'text/html',
+ base64: 'PGh0bWw+PC9odG1sPgo=',
+ }),
+ });
+ const b = response.data;
+ expect(b.name).toMatch(/_file.html$/);
+ expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/);
});
});
});
diff --git a/src/Config.js b/src/Config.js
index 812d28c367..1cd941efe9 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -460,6 +460,11 @@ export class Config {
} else if (typeof fileUpload.enableForAuthenticatedUser !== 'boolean') {
throw 'fileUpload.enableForAuthenticatedUser must be a boolean value.';
}
+ if (fileUpload.fileExtensions === undefined) {
+ fileUpload.fileExtensions = FileUploadOptions.fileExtensions.default;
+ } else if (!Array.isArray(fileUpload.fileExtensions)) {
+ throw 'fileUpload.fileExtensions must be an array.';
+ }
}
static validateIps(field, masterKeyIps) {
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 7987363ff2..85dbeaa83f 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -969,6 +969,13 @@ module.exports.FileUploadOptions = {
action: parsers.booleanParser,
default: false,
},
+ fileExtensions: {
+ env: 'PARSE_SERVER_FILE_UPLOAD_FILE_EXTENSIONS',
+ help:
+ "Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.
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.
Defaults to `^[^hH][^tT][^mM][^lL]?$` which allows any file extension except HTML files.",
+ action: parsers.arrayParser,
+ default: ['^[^hH][^tT][^mM][^lL]?$'],
+ },
};
module.exports.DatabaseOptions = {
enableSchemaHooks: {
diff --git a/src/Options/docs.js b/src/Options/docs.js
index b5a78aace1..3b48bc2a20 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -221,6 +221,7 @@
* @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users.
* @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users.
* @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication.
+ * @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.
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.
Defaults to `^[^hH][^tT][^mM][^lL]?$` which allows any file extension except HTML files.
*/
/**
diff --git a/src/Options/index.js b/src/Options/index.js
index 009b31a5d5..fc4b269f0a 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -537,6 +537,9 @@ export interface PasswordPolicyOptions {
}
export interface FileUploadOptions {
+ /* Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.
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.
Defaults to `^[^hH][^tT][^mM][^lL]?$` which allows any file extension except HTML files.
+ :DEFAULT: ["^[^hH][^tT][^mM][^lL]?$"] */
+ fileExtensions: ?(string[]);
/* Is true if file upload should be allowed for anonymous users.
:DEFAULT: false */
enableForAnonymousUser: ?boolean;
diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js
index e911d772a4..ed48a28a68 100644
--- a/src/Routers/FilesRouter.js
+++ b/src/Routers/FilesRouter.js
@@ -140,6 +140,38 @@ export class FilesRouter {
return;
}
+ const fileExtensions = config.fileUpload?.fileExtensions;
+ if (!isMaster && fileExtensions) {
+ const isValidExtension = extension => {
+ return fileExtensions.some(ext => {
+ if (ext === '*') {
+ return true;
+ }
+ const regex = new RegExp(fileExtensions);
+ if (regex.test(extension)) {
+ return true;
+ }
+ });
+ };
+ let extension = contentType;
+ if (filename && filename.includes('.')) {
+ extension = filename.split('.')[1];
+ } else if (contentType && contentType.includes('/')) {
+ extension = contentType.split('/')[1];
+ }
+ extension = extension.split(' ').join('');
+
+ if (!isValidExtension(extension)) {
+ next(
+ new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ `File upload of extension ${extension} is disabled.`
+ )
+ );
+ return;
+ }
+ }
+
const base64 = req.body.toString('base64');
const file = new Parse.File(filename, { base64 }, contentType);
const { metadata = {}, tags = {} } = req.fileData || {};