Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
38 changes: 33 additions & 5 deletions src/Routers/FilesRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -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;
Expand All @@ -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(() => {
Expand All @@ -215,7 +242,7 @@ export class FilesRouter {
triggers.Types.afterFind,
{ file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } },
config,
req.auth
fileAuth
);

if (afterFind?.file) {
Expand Down Expand Up @@ -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;
Expand All @@ -756,7 +784,7 @@ export class FilesRouter {
triggers.Types.afterFind,
{ file },
config,
req.auth
fileAuth
);
res.status(200);
res.json(data);
Expand Down
Loading