Skip to content

Commit

Permalink
feat: Add new Parse Server option fileUpload.fileExtensions to rest…
Browse files Browse the repository at this point in the history
…rict 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)
  • Loading branch information
mtrezza authored May 20, 2023
1 parent 832702d commit a318e7b
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 25 deletions.
186 changes: 161 additions & 25 deletions spec/ParseFile.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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('<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 => {
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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: '<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$/);
});
});
});
5 changes: 5 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.<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 = {
enableSchemaHooks: {
Expand Down
1 change: 1 addition & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.<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.
:DEFAULT: false */
enableForAnonymousUser: ?boolean;
Expand Down
32 changes: 32 additions & 0 deletions src/Routers/FilesRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
Expand Down

0 comments on commit a318e7b

Please sign in to comment.