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; 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 ['.*'] (#8538)
This commit is contained in:
@@ -37,8 +37,14 @@ describe('Parse.File testing', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('works with _ContentType', done => {
|
it('works with _ContentType', async () => {
|
||||||
request({
|
await reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
enableForPublic: true,
|
||||||
|
fileExtensions: ['*'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let response = await request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: 'http://localhost:8378/1/files/file',
|
url: 'http://localhost:8378/1/files/file',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -47,21 +53,18 @@ describe('Parse.File testing', () => {
|
|||||||
_ContentType: 'text/html',
|
_ContentType: 'text/html',
|
||||||
base64: 'PGh0bWw+PC9odG1sPgo=',
|
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('<html></html>\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('<html></html>\n');
|
||||||
|
} catch (e) {
|
||||||
|
jfail(e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('works without Content-Type', done => {
|
it('works without Content-Type', done => {
|
||||||
@@ -351,25 +354,28 @@ describe('Parse.File testing', () => {
|
|||||||
ok(object.toJSON().file.url);
|
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 = {
|
const headers = {
|
||||||
'Content-Type': 'text/html',
|
'Content-Type': 'text/html',
|
||||||
'X-Parse-Application-Id': 'test',
|
'X-Parse-Application-Id': 'test',
|
||||||
'X-Parse-REST-API-Key': 'rest',
|
'X-Parse-REST-API-Key': 'rest',
|
||||||
};
|
};
|
||||||
request({
|
let response = await request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
url: 'http://localhost:8378/1/files/file',
|
url: 'http://localhost:8378/1/files/file',
|
||||||
body: 'fee fi fo',
|
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 => {
|
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: { [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: '<html></html>\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: '<html></html>\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$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -460,6 +460,11 @@ export class Config {
|
|||||||
} else if (typeof fileUpload.enableForAuthenticatedUser !== 'boolean') {
|
} else if (typeof fileUpload.enableForAuthenticatedUser !== 'boolean') {
|
||||||
throw 'fileUpload.enableForAuthenticatedUser must be a boolean value.';
|
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) {
|
static validateIps(field, masterKeyIps) {
|
||||||
|
|||||||
@@ -969,6 +969,13 @@ module.exports.FileUploadOptions = {
|
|||||||
action: parsers.booleanParser,
|
action: parsers.booleanParser,
|
||||||
default: false,
|
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.<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.",
|
||||||
|
action: parsers.arrayParser,
|
||||||
|
default: ['^[^hH][^tT][^mM][^lL]?$'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
module.exports.DatabaseOptions = {
|
module.exports.DatabaseOptions = {
|
||||||
enableSchemaHooks: {
|
enableSchemaHooks: {
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
* @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users.
|
* @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} 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 {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.<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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -537,6 +537,9 @@ export interface PasswordPolicyOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FileUploadOptions {
|
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.<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.
|
||||||
|
:DEFAULT: ["^[^hH][^tT][^mM][^lL]?$"] */
|
||||||
|
fileExtensions: ?(string[]);
|
||||||
/* Is true if file upload should be allowed for anonymous users.
|
/* Is true if file upload should be allowed for anonymous users.
|
||||||
:DEFAULT: false */
|
:DEFAULT: false */
|
||||||
enableForAnonymousUser: ?boolean;
|
enableForAnonymousUser: ?boolean;
|
||||||
|
|||||||
@@ -140,6 +140,38 @@ export class FilesRouter {
|
|||||||
return;
|
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 base64 = req.body.toString('base64');
|
||||||
const file = new Parse.File(filename, { base64 }, contentType);
|
const file = new Parse.File(filename, { base64 }, contentType);
|
||||||
const { metadata = {}, tags = {} } = req.fileData || {};
|
const { metadata = {}, tags = {} } = req.fileData || {};
|
||||||
|
|||||||
Reference in New Issue
Block a user