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 || {};