fix: Streaming file download bypasses afterFind file trigger authorization ([GHSA-hpm8-9qx6-jvwv](https://github.com/parse-community/parse-server/security/advisories/GHSA-hpm8-9qx6-jvwv)) (#10362)

This commit is contained in:
Manuel
2026-03-31 00:18:32 +01:00
committed by GitHub
parent 83e7949196
commit 053109b3ee
2 changed files with 97 additions and 4 deletions
+65
View File
@@ -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);
});
});
});
+32 -4
View File
@@ -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);