diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 41aa1529bf..15ed53ea64 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -5538,4 +5538,69 @@ describe('Vulnerabilities', () => { expect(contextAfterDelete.isAdmin).toBeUndefined(); }); }); + + 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 6125c3e8db..4563e87178 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 { Readable } from 'stream'; import { createSanitizedHttpError } from '../Error'; @@ -120,6 +121,22 @@ export class FilesRouter { return Array.isArray(parts) ? parts.join('/') : parts; } + 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; + } + } + static validateDirectory(directory) { if (typeof directory !== 'string') { return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory must be a string.'); @@ -177,11 +194,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; @@ -191,7 +209,16 @@ export class FilesRouter { const defaultResponseHeaders = { 'X-Content-Type-Options': 'nosniff' }; if (isFileStreamable(req, filesController)) { - for (const [key, value] of Object.entries(defaultResponseHeaders)) { + const afterFind = await triggers.maybeRunFileTrigger( + triggers.Types.afterFind, + { file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } }, + config, + fileAuth + ); + if (afterFind?.forceDownload) { + res.set('Content-Disposition', `attachment;filename=${afterFind.file?._name || filename}`); + } + for (const [key, value] of Object.entries(afterFind?.responseHeaders ?? defaultResponseHeaders)) { res.set(key, value); } filesController.handleFileStream(config, filename, req, res, contentType).catch(() => { @@ -215,7 +242,7 @@ export class FilesRouter { triggers.Types.afterFind, { file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } }, config, - req.auth + fileAuth ); if (afterFind?.file) { @@ -736,11 +763,12 @@ export class FilesRouter { const { filesController } = config; let filename = FilesRouter._getFilenameFromParams(req); 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; @@ -756,7 +784,7 @@ export class FilesRouter { triggers.Types.afterFind, { file }, config, - req.auth + fileAuth ); res.status(200); res.json(data);