From 140290b38d09af3e1359e9c492f69cd0f2e68d48 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:15:52 +0100 Subject: [PATCH] fix: Streaming file download bypasses afterFind(Parse.File) trigger and validators --- spec/vulnerabilities.spec.js | 65 ++++++++++++++++++++++++++++++++++++ src/Routers/FilesRouter.js | 36 +++++++++++++++++--- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 609f6f9b75..457393b31a 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -4947,4 +4947,69 @@ describe('Vulnerabilities', () => { }); }); }); + + describe('(GHSA-hpm8-9qx6-jvwv) Ranged file download bypasses afterFind(Parse.File) trigger and validators', () => { + it_only_db('mongo')('enforces afterFind requireUser validator on streaming file download', async () => { + const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Range': 'bytes=0-2', + }, + }).catch(e => e); + expect(response.status).toBe(403); + }); + + it('enforces afterFind requireUser validator on non-streaming file download', async () => { + const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }).catch(e => e); + expect(response.status).toBe(403); + }); + + it_only_db('mongo')('allows streaming file download when afterFind requireUser validator passes', async () => { + const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + 'Range': 'bytes=0-2', + }, + }).catch(e => e); + expect(response.status).toBe(206); + }); + + it_only_db('mongo')('enforces afterFind custom authorization on streaming file download', async () => { + const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, () => { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Access denied'); + }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Range': 'bytes=0-2', + }, + }).catch(e => e); + expect(response.status).toBe(403); + }); + }); }); diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 0f4dbfda86..192b14afd0 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -5,6 +5,7 @@ import Config from '../Config'; import logger from '../logger'; const triggers = require('../triggers'); const Utils = require('../Utils'); +const auth = require('../Auth'); import { createSanitizedHttpError } from '../Error'; export class FilesRouter { @@ -40,6 +41,22 @@ export class FilesRouter { return router; } + static async _resolveAuth(req, config) { + const sessionToken = req.get('X-Parse-Session-Token'); + if (!sessionToken) { + return null; + } + try { + return await auth.getAuthForSessionToken({ + config, + sessionToken, + installationId: req.get('X-Parse-Installation-Id'), + }); + } catch { + return null; + } + } + async getHandler(req, res) { const config = Config.get(req.params.appId); if (!config) { @@ -54,11 +71,12 @@ export class FilesRouter { const mime = (await import('mime')).default; let contentType = mime.getType(filename); let file = new Parse.File(filename, { base64: '' }, contentType); + const fileAuth = await FilesRouter._resolveAuth(req, config); const triggerResult = await triggers.maybeRunFileTrigger( triggers.Types.beforeFind, { file }, config, - req.auth + fileAuth ); if (triggerResult?.file?._name) { filename = triggerResult?.file?._name; @@ -66,6 +84,15 @@ export class FilesRouter { } if (isFileStreamable(req, filesController)) { + const afterFind = await triggers.maybeRunFileTrigger( + triggers.Types.afterFind, + { file, forceDownload: false }, + config, + fileAuth + ); + if (afterFind?.forceDownload) { + res.set('Content-Disposition', `attachment;filename=${afterFind.file?._name || filename}`); + } filesController.handleFileStream(config, filename, req, res, contentType).catch(() => { res.status(404); res.set('Content-Type', 'text/plain'); @@ -87,7 +114,7 @@ export class FilesRouter { triggers.Types.afterFind, { file, forceDownload: false }, config, - req.auth + fileAuth ); if (afterFind?.file) { @@ -326,11 +353,12 @@ export class FilesRouter { const { filesController } = config; let { filename } = req.params; const file = new Parse.File(filename, { base64: '' }); + const fileAuth = await FilesRouter._resolveAuth(req, config); const triggerResult = await triggers.maybeRunFileTrigger( triggers.Types.beforeFind, { file }, config, - req.auth + fileAuth ); if (triggerResult?.file?._name) { filename = triggerResult.file._name; @@ -346,7 +374,7 @@ export class FilesRouter { triggers.Types.afterFind, { file }, config, - req.auth + fileAuth ); res.status(200); res.json(data);