Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions spec/CloudCode.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4453,6 +4453,39 @@ describe('Parse.File hooks', () => {
expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`);
});

it('can set custom response headers in afterFind', async () => {
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
await file.save({ useMasterKey: true });
Parse.Cloud.afterFind(Parse.File, req => {
req.responseHeaders['X-Custom-Header'] = 'custom-value';
});
const response = await request({
url: file.url(),
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
});
expect(response.headers['x-custom-header']).toBe('custom-value');
expect(response.headers['x-content-type-options']).toBe('nosniff');
});

it('can override default response headers in afterFind', async () => {
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
await file.save({ useMasterKey: true });
Parse.Cloud.afterFind(Parse.File, req => {
delete req.responseHeaders['X-Content-Type-Options'];
});
const response = await request({
url: file.url(),
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
});
expect(response.headers['x-content-type-options']).toBeUndefined();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('beforeFind blocks metadata endpoint', async () => {
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
await file.save({ useMasterKey: true });
Expand Down
29 changes: 29 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -859,3 +859,32 @@
client.close();
});
});

describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => {
it('sets X-Content-Type-Options: nosniff on file GET response', async () => {

Check failure on line 864 in spec/vulnerabilities.spec.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 2 spaces but found 4
const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain');

Check failure on line 865 in spec/vulnerabilities.spec.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 4 spaces but found 6
await file.save({ useMasterKey: true });

Check failure on line 866 in spec/vulnerabilities.spec.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 4 spaces but found 6
const response = await request({

Check failure on line 867 in spec/vulnerabilities.spec.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 4 spaces but found 6
url: file.url(),

Check failure on line 868 in spec/vulnerabilities.spec.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 6 spaces but found 8
headers: {

Check failure on line 869 in spec/vulnerabilities.spec.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 6 spaces but found 8
'X-Parse-Application-Id': 'test',

Check failure on line 870 in spec/vulnerabilities.spec.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 8 spaces but found 10
'X-Parse-REST-API-Key': 'rest',

Check failure on line 871 in spec/vulnerabilities.spec.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 8 spaces but found 10
},

Check failure on line 872 in spec/vulnerabilities.spec.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 6 spaces but found 8
});

Check failure on line 873 in spec/vulnerabilities.spec.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 4 spaces but found 6
expect(response.headers['x-content-type-options']).toBe('nosniff');
});

it('sets X-Content-Type-Options: nosniff on streaming file GET response', async () => {
const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain');
await file.save({ useMasterKey: true });
const response = await request({
url: file.url(),
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Range': 'bytes=0-2',
},
});
expect(response.headers['x-content-type-options']).toBe('nosniff');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});
12 changes: 11 additions & 1 deletion src/Routers/FilesRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,12 @@ export class FilesRouter {
contentType = mime.getType(filename);
}

const defaultResponseHeaders = { 'X-Content-Type-Options': 'nosniff' };

if (isFileStreamable(req, filesController)) {
for (const [key, value] of Object.entries(defaultResponseHeaders)) {
res.set(key, value);
}
filesController.handleFileStream(config, filename, req, res, contentType).catch(() => {
res.status(404);
res.set('Content-Type', 'text/plain');
Expand All @@ -208,7 +213,7 @@ export class FilesRouter {
file = new Parse.File(filename, { base64: data.toString('base64') }, contentType);
const afterFind = await triggers.maybeRunFileTrigger(
triggers.Types.afterFind,
{ file, forceDownload: false },
{ file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } },
config,
req.auth
);
Expand All @@ -224,6 +229,11 @@ export class FilesRouter {
if (afterFind.forceDownload) {
res.set('Content-Disposition', `attachment;filename=${afterFind.file._name}`);
}
if (afterFind.responseHeaders) {
for (const [key, value] of Object.entries(afterFind.responseHeaders)) {
res.set(key, value);
}
}
res.end(data);
} catch (e) {
const err = triggers.resolveError(e, {
Expand Down
2 changes: 2 additions & 0 deletions src/cloud-code/Parse.Cloud.js
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,8 @@ module.exports = ParseCloud;
* @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`)
* @property {Object} log The current logger inside Parse Server.
* @property {Object} config The Parse Server config.
* @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.
* @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.
*/

/**
Expand Down
3 changes: 3 additions & 0 deletions src/triggers.js
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,9 @@ export async function maybeRunFileTrigger(triggerType, fileObject, config, auth)
if (request.forceDownload) {
fileObject.forceDownload = true;
}
if (request.responseHeaders) {
fileObject.responseHeaders = request.responseHeaders;
}
logTriggerSuccessBeforeHook(
triggerType,
'Parse.File',
Expand Down
Loading