From 205357253019e1d01349bccee0071675be813f0e Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:45:03 +0000 Subject: [PATCH 1/3] fix --- spec/CloudCode.spec.js | 33 +++++++++++++++++++++++++++++++++ spec/vulnerabilities.spec.js | 30 ++++++++++++++++++++++++++++++ src/Routers/FilesRouter.js | 12 +++++++++++- src/cloud-code/Parse.Cloud.js | 2 ++ src/triggers.js | 3 +++ 5 files changed, 79 insertions(+), 1 deletion(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 544c535bc5..86085a3faf 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -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(); + }); + it('beforeFind blocks metadata endpoint', async () => { const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); await file.save({ useMasterKey: true }); diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 3ce1be2e3f..ce848bf7a1 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -858,4 +858,34 @@ describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () expect(elapsed).toBeLessThan(timeout + 1000); client.close(); }); + + describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { + it('sets X-Content-Type-Options: nosniff on 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', + }, + }); + 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'); + }); + + }); }); diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 721e8eade6..112f38f7c3 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -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'); @@ -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 ); @@ -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, { diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 88aedf080f..7f2aded387 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -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. */ /** diff --git a/src/triggers.js b/src/triggers.js index 2ba470ed90..a2daec42cf 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -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', From 9109fcb9c0e65d722bbad8014231924ab6dcf7a1 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:00:06 +0000 Subject: [PATCH 2/3] Update vulnerabilities.spec.js --- spec/vulnerabilities.spec.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index ce848bf7a1..1fe70cee73 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -858,8 +858,9 @@ describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () expect(elapsed).toBeLessThan(timeout + 1000); client.close(); }); +}); - describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { +describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { it('sets X-Content-Type-Options: nosniff on file GET response', async () => { const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); await file.save({ useMasterKey: true }); @@ -886,6 +887,4 @@ describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () }); expect(response.headers['x-content-type-options']).toBe('nosniff'); }); - - }); }); From 471957abd3867efd1aa6ddf6cbdb047c40e1270c Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:03:20 +0000 Subject: [PATCH 3/3] lint --- spec/vulnerabilities.spec.js | 46 ++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 1fe70cee73..4dfc0bd338 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -861,30 +861,30 @@ describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () }); describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { - it('sets X-Content-Type-Options: nosniff on 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', - }, - }); - expect(response.headers['x-content-type-options']).toBe('nosniff'); + it('sets X-Content-Type-Options: nosniff on 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', + }, }); + 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'); + 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'); + }); });