Skip to content

Commit 28d11a3

Browse files
authored
feat: Add X-Content-Type-Options: nosniff header and customizable response headers for files via Parse.Cloud.afterFind(Parse.File) (#10158)
1 parent 510b898 commit 28d11a3

File tree

5 files changed

+78
-1
lines changed

5 files changed

+78
-1
lines changed

spec/CloudCode.spec.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4453,6 +4453,39 @@ describe('Parse.File hooks', () => {
44534453
expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`);
44544454
});
44554455

4456+
it('can set custom response headers in afterFind', async () => {
4457+
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
4458+
await file.save({ useMasterKey: true });
4459+
Parse.Cloud.afterFind(Parse.File, req => {
4460+
req.responseHeaders['X-Custom-Header'] = 'custom-value';
4461+
});
4462+
const response = await request({
4463+
url: file.url(),
4464+
headers: {
4465+
'X-Parse-Application-Id': 'test',
4466+
'X-Parse-REST-API-Key': 'rest',
4467+
},
4468+
});
4469+
expect(response.headers['x-custom-header']).toBe('custom-value');
4470+
expect(response.headers['x-content-type-options']).toBe('nosniff');
4471+
});
4472+
4473+
it('can override default response headers in afterFind', async () => {
4474+
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
4475+
await file.save({ useMasterKey: true });
4476+
Parse.Cloud.afterFind(Parse.File, req => {
4477+
delete req.responseHeaders['X-Content-Type-Options'];
4478+
});
4479+
const response = await request({
4480+
url: file.url(),
4481+
headers: {
4482+
'X-Parse-Application-Id': 'test',
4483+
'X-Parse-REST-API-Key': 'rest',
4484+
},
4485+
});
4486+
expect(response.headers['x-content-type-options']).toBeUndefined();
4487+
});
4488+
44564489
it('beforeFind blocks metadata endpoint', async () => {
44574490
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
44584491
await file.save({ useMasterKey: true });

spec/vulnerabilities.spec.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,3 +859,32 @@ describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', ()
859859
client.close();
860860
});
861861
});
862+
863+
describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => {
864+
it('sets X-Content-Type-Options: nosniff on file GET response', async () => {
865+
const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain');
866+
await file.save({ useMasterKey: true });
867+
const response = await request({
868+
url: file.url(),
869+
headers: {
870+
'X-Parse-Application-Id': 'test',
871+
'X-Parse-REST-API-Key': 'rest',
872+
},
873+
});
874+
expect(response.headers['x-content-type-options']).toBe('nosniff');
875+
});
876+
877+
it('sets X-Content-Type-Options: nosniff on streaming file GET response', async () => {
878+
const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain');
879+
await file.save({ useMasterKey: true });
880+
const response = await request({
881+
url: file.url(),
882+
headers: {
883+
'X-Parse-Application-Id': 'test',
884+
'X-Parse-REST-API-Key': 'rest',
885+
'Range': 'bytes=0-2',
886+
},
887+
});
888+
expect(response.headers['x-content-type-options']).toBe('nosniff');
889+
});
890+
});

src/Routers/FilesRouter.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,12 @@ export class FilesRouter {
188188
contentType = mime.getType(filename);
189189
}
190190

191+
const defaultResponseHeaders = { 'X-Content-Type-Options': 'nosniff' };
192+
191193
if (isFileStreamable(req, filesController)) {
194+
for (const [key, value] of Object.entries(defaultResponseHeaders)) {
195+
res.set(key, value);
196+
}
192197
filesController.handleFileStream(config, filename, req, res, contentType).catch(() => {
193198
res.status(404);
194199
res.set('Content-Type', 'text/plain');
@@ -208,7 +213,7 @@ export class FilesRouter {
208213
file = new Parse.File(filename, { base64: data.toString('base64') }, contentType);
209214
const afterFind = await triggers.maybeRunFileTrigger(
210215
triggers.Types.afterFind,
211-
{ file, forceDownload: false },
216+
{ file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } },
212217
config,
213218
req.auth
214219
);
@@ -224,6 +229,11 @@ export class FilesRouter {
224229
if (afterFind.forceDownload) {
225230
res.set('Content-Disposition', `attachment;filename=${afterFind.file._name}`);
226231
}
232+
if (afterFind.responseHeaders) {
233+
for (const [key, value] of Object.entries(afterFind.responseHeaders)) {
234+
res.set(key, value);
235+
}
236+
}
227237
res.end(data);
228238
} catch (e) {
229239
const err = triggers.resolveError(e, {

src/cloud-code/Parse.Cloud.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,8 @@ module.exports = ParseCloud;
757757
* @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`)
758758
* @property {Object} log The current logger inside Parse Server.
759759
* @property {Object} config The Parse Server config.
760+
* @property {Boolean} forceDownload (afterFind only) If set to `true`, the file response will include a `Content-Disposition: attachment` header, prompting the browser to download the file instead of displaying it inline.
761+
* @property {Object} responseHeaders (afterFind only) The headers that will be set on the file response. By default contains `{ 'X-Content-Type-Options': 'nosniff' }`. Modify this object to add, change, or remove response headers.
760762
*/
761763

762764
/**

src/triggers.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,6 +1076,9 @@ export async function maybeRunFileTrigger(triggerType, fileObject, config, auth)
10761076
if (request.forceDownload) {
10771077
fileObject.forceDownload = true;
10781078
}
1079+
if (request.responseHeaders) {
1080+
fileObject.responseHeaders = request.responseHeaders;
1081+
}
10791082
logTriggerSuccessBeforeHook(
10801083
triggerType,
10811084
'Parse.File',

0 commit comments

Comments
 (0)