Skip to content

Commit 053109b

Browse files
authored
fix: Streaming file download bypasses afterFind file trigger authorization ([GHSA-hpm8-9qx6-jvwv](GHSA-hpm8-9qx6-jvwv)) (#10362)
1 parent 83e7949 commit 053109b

2 files changed

Lines changed: 97 additions & 4 deletions

File tree

spec/vulnerabilities.spec.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4947,4 +4947,69 @@ describe('Vulnerabilities', () => {
49474947
});
49484948
});
49494949
});
4950+
4951+
describe('(GHSA-hpm8-9qx6-jvwv) Ranged file download bypasses afterFind(Parse.File) trigger and validators', () => {
4952+
it_only_db('mongo')('enforces afterFind requireUser validator on streaming file download', async () => {
4953+
const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
4954+
await file.save({ useMasterKey: true });
4955+
Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true });
4956+
const response = await request({
4957+
url: file.url(),
4958+
headers: {
4959+
'X-Parse-Application-Id': 'test',
4960+
'X-Parse-REST-API-Key': 'rest',
4961+
'Range': 'bytes=0-2',
4962+
},
4963+
}).catch(e => e);
4964+
expect(response.status).toBe(403);
4965+
});
4966+
4967+
it('enforces afterFind requireUser validator on non-streaming file download', async () => {
4968+
const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
4969+
await file.save({ useMasterKey: true });
4970+
Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true });
4971+
const response = await request({
4972+
url: file.url(),
4973+
headers: {
4974+
'X-Parse-Application-Id': 'test',
4975+
'X-Parse-REST-API-Key': 'rest',
4976+
},
4977+
}).catch(e => e);
4978+
expect(response.status).toBe(403);
4979+
});
4980+
4981+
it_only_db('mongo')('allows streaming file download when afterFind requireUser validator passes', async () => {
4982+
const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
4983+
await file.save({ useMasterKey: true });
4984+
const user = await Parse.User.signUp('username', 'password');
4985+
Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true });
4986+
const response = await request({
4987+
url: file.url(),
4988+
headers: {
4989+
'X-Parse-Application-Id': 'test',
4990+
'X-Parse-REST-API-Key': 'rest',
4991+
'X-Parse-Session-Token': user.getSessionToken(),
4992+
'Range': 'bytes=0-2',
4993+
},
4994+
}).catch(e => e);
4995+
expect(response.status).toBe(206);
4996+
});
4997+
4998+
it_only_db('mongo')('enforces afterFind custom authorization on streaming file download', async () => {
4999+
const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
5000+
await file.save({ useMasterKey: true });
5001+
Parse.Cloud.afterFind(Parse.File, () => {
5002+
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Access denied');
5003+
});
5004+
const response = await request({
5005+
url: file.url(),
5006+
headers: {
5007+
'X-Parse-Application-Id': 'test',
5008+
'X-Parse-REST-API-Key': 'rest',
5009+
'Range': 'bytes=0-2',
5010+
},
5011+
}).catch(e => e);
5012+
expect(response.status).toBe(403);
5013+
});
5014+
});
49505015
});

src/Routers/FilesRouter.js

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Config from '../Config';
55
import logger from '../logger';
66
const triggers = require('../triggers');
77
const Utils = require('../Utils');
8+
const auth = require('../Auth');
89
import { createSanitizedHttpError } from '../Error';
910

1011
export class FilesRouter {
@@ -40,6 +41,22 @@ export class FilesRouter {
4041
return router;
4142
}
4243

44+
static async _resolveAuth(req, config) {
45+
const sessionToken = req.get('X-Parse-Session-Token');
46+
if (!sessionToken) {
47+
return null;
48+
}
49+
try {
50+
return await auth.getAuthForSessionToken({
51+
config,
52+
sessionToken,
53+
installationId: req.get('X-Parse-Installation-Id'),
54+
});
55+
} catch {
56+
return null;
57+
}
58+
}
59+
4360
async getHandler(req, res) {
4461
const config = Config.get(req.params.appId);
4562
if (!config) {
@@ -54,18 +71,28 @@ export class FilesRouter {
5471
const mime = (await import('mime')).default;
5572
let contentType = mime.getType(filename);
5673
let file = new Parse.File(filename, { base64: '' }, contentType);
74+
const fileAuth = await FilesRouter._resolveAuth(req, config);
5775
const triggerResult = await triggers.maybeRunFileTrigger(
5876
triggers.Types.beforeFind,
5977
{ file },
6078
config,
61-
req.auth
79+
fileAuth
6280
);
6381
if (triggerResult?.file?._name) {
6482
filename = triggerResult?.file?._name;
6583
contentType = mime.getType(filename);
6684
}
6785

6886
if (isFileStreamable(req, filesController)) {
87+
const afterFind = await triggers.maybeRunFileTrigger(
88+
triggers.Types.afterFind,
89+
{ file, forceDownload: false },
90+
config,
91+
fileAuth
92+
);
93+
if (afterFind?.forceDownload) {
94+
res.set('Content-Disposition', `attachment;filename=${afterFind.file?._name || filename}`);
95+
}
6996
filesController.handleFileStream(config, filename, req, res, contentType).catch(() => {
7097
res.status(404);
7198
res.set('Content-Type', 'text/plain');
@@ -87,7 +114,7 @@ export class FilesRouter {
87114
triggers.Types.afterFind,
88115
{ file, forceDownload: false },
89116
config,
90-
req.auth
117+
fileAuth
91118
);
92119

93120
if (afterFind?.file) {
@@ -326,11 +353,12 @@ export class FilesRouter {
326353
const { filesController } = config;
327354
let { filename } = req.params;
328355
const file = new Parse.File(filename, { base64: '' });
356+
const fileAuth = await FilesRouter._resolveAuth(req, config);
329357
const triggerResult = await triggers.maybeRunFileTrigger(
330358
triggers.Types.beforeFind,
331359
{ file },
332360
config,
333-
req.auth
361+
fileAuth
334362
);
335363
if (triggerResult?.file?._name) {
336364
filename = triggerResult.file._name;
@@ -346,7 +374,7 @@ export class FilesRouter {
346374
triggers.Types.afterFind,
347375
{ file },
348376
config,
349-
req.auth
377+
fileAuth
350378
);
351379
res.status(200);
352380
res.json(data);

0 commit comments

Comments
 (0)