mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user