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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ The following table lists all route groups covered by `routeAllowList` with exam
| Purchase validation | `validate_purchase` | `validate_purchase` |

> [!NOTE]
> File upload, file download, and file metadata routes are not covered by `routeAllowList`. File upload access is controlled via the `fileUpload` option.
> File routes are not covered by `routeAllowList`. File upload access is controlled via the `fileUpload` option. File download and metadata access is controlled via the `fileDownload` option.

## Email Verification and Password Reset

Expand Down
2 changes: 2 additions & 0 deletions resources/buildConfigDefinitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const parsers = require('../src/Options/parsers');
const nestedOptionTypes = [
'CustomPagesOptions',
'DatabaseOptions',
'FileDownloadOptions',
'FileUploadOptions',
'IdempotencyOptions',
'Object',
Expand All @@ -34,6 +35,7 @@ const nestedOptionEnvPrefix = {
DatabaseOptionsClientMetadata: 'PARSE_SERVER_DATABASE_CLIENT_METADATA_',
CustomPagesOptions: 'PARSE_SERVER_CUSTOM_PAGES_',
DatabaseOptions: 'PARSE_SERVER_DATABASE_',
FileDownloadOptions: 'PARSE_SERVER_FILE_DOWNLOAD_',
FileUploadOptions: 'PARSE_SERVER_FILE_UPLOAD_',
IdempotencyOptions: 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_',
LiveQueryOptions: 'PARSE_SERVER_LIVEQUERY_',
Expand Down
282 changes: 282 additions & 0 deletions spec/FileDownload.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
'use strict';

describe('fileDownload', () => {
describe('config validation', () => {
it('should default all flags to true when fileDownload is undefined', async () => {
await reconfigureServer({ fileDownload: undefined });
const Config = require('../lib/Config');
const config = Config.get(Parse.applicationId);
expect(config.fileDownload.enableForAnonymousUser).toBe(true);
expect(config.fileDownload.enableForAuthenticatedUser).toBe(true);
expect(config.fileDownload.enableForPublic).toBe(true);
});

it('should accept valid boolean values', async () => {
await reconfigureServer({
fileDownload: {
enableForAnonymousUser: false,
enableForAuthenticatedUser: false,
enableForPublic: false,
},
});
const Config = require('../lib/Config');
const config = Config.get(Parse.applicationId);
expect(config.fileDownload.enableForAnonymousUser).toBe(false);
expect(config.fileDownload.enableForAuthenticatedUser).toBe(false);
expect(config.fileDownload.enableForPublic).toBe(false);
});

it('should reject non-object values', async () => {
for (const value of ['string', 123, true, []]) {
await expectAsync(reconfigureServer({ fileDownload: value })).toBeRejected();
}
});

it('should reject non-boolean flag values', async () => {
await expectAsync(
reconfigureServer({ fileDownload: { enableForAnonymousUser: 'yes' } })
).toBeRejected();
await expectAsync(
reconfigureServer({ fileDownload: { enableForAuthenticatedUser: 1 } })
).toBeRejected();
await expectAsync(
reconfigureServer({ fileDownload: { enableForPublic: null } })
).toBeRejected();
});
});

describe('permissions', () => {
async function uploadTestFile() {
const request = require('../lib/request');
const res = await request({
headers: {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
method: 'POST',
url: 'http://localhost:8378/1/files/test.txt',
body: 'hello world',
});
return res.data;
}

it('should allow public download by default', async () => {
await reconfigureServer();
const file = await uploadTestFile();
const request = require('../lib/request');
const res = await request({
method: 'GET',
url: file.url,
});
expect(res.status).toBe(200);
});

it('should block public download when enableForPublic is false', async () => {
await reconfigureServer({
fileDownload: { enableForPublic: false },
});
const file = await uploadTestFile();
const request = require('../lib/request');
try {
await request({
method: 'GET',
url: file.url,
});
fail('should have thrown');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
});

it('should allow authenticated user download when enableForAuthenticatedUser is true', async () => {
await reconfigureServer({
fileDownload: { enableForPublic: false, enableForAuthenticatedUser: true },
});
const file = await uploadTestFile();
const user = new Parse.User();
user.set('username', 'testuser');
user.set('password', 'testpass');
await user.signUp();
const request = require('../lib/request');
const res = await request({
headers: {
'X-Parse-Session-Token': user.getSessionToken(),
},
method: 'GET',
url: file.url,
});
expect(res.status).toBe(200);
});

it('should block authenticated user download when enableForAuthenticatedUser is false', async () => {
await reconfigureServer({
fileDownload: { enableForAuthenticatedUser: false },
});
const file = await uploadTestFile();
const user = new Parse.User();
user.set('username', 'testuser');
user.set('password', 'testpass');
await user.signUp();
const request = require('../lib/request');
try {
await request({
headers: {
'X-Parse-Session-Token': user.getSessionToken(),
},
method: 'GET',
url: file.url,
});
fail('should have thrown');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
});

it('should block anonymous user download when enableForAnonymousUser is false', async () => {
await reconfigureServer({
fileDownload: { enableForAnonymousUser: false },
});
const file = await uploadTestFile();
const user = await Parse.AnonymousUtils.logIn();
const request = require('../lib/request');
try {
await request({
headers: {
'X-Parse-Session-Token': user.getSessionToken(),
},
method: 'GET',
url: file.url,
});
fail('should have thrown');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
});

it('should allow anonymous user download when enableForAnonymousUser is true', async () => {
await reconfigureServer({
fileDownload: { enableForAnonymousUser: true, enableForPublic: false },
});
const file = await uploadTestFile();
const user = await Parse.AnonymousUtils.logIn();
const request = require('../lib/request');
const res = await request({
headers: {
'X-Parse-Session-Token': user.getSessionToken(),
},
method: 'GET',
url: file.url,
});
expect(res.status).toBe(200);
});

it('should allow master key to bypass all restrictions', async () => {
await reconfigureServer({
fileDownload: {
enableForAnonymousUser: false,
enableForAuthenticatedUser: false,
enableForPublic: false,
},
});
const file = await uploadTestFile();
const request = require('../lib/request');
const res = await request({
headers: {
'X-Parse-Master-Key': 'test',
},
method: 'GET',
url: file.url,
});
expect(res.status).toBe(200);
});

it('should block metadata endpoint when download is disabled for public', async () => {
await reconfigureServer({
fileDownload: { enableForPublic: false },
});
const file = await uploadTestFile();
const request = require('../lib/request');
// The file URL is like http://localhost:8378/1/files/test/abc_test.txt
// The metadata URL replaces /files/APPID/ with /files/APPID/metadata/
const url = new URL(file.url);
const pathParts = url.pathname.split('/');
// pathParts: ['', '1', 'files', 'test', 'abc_test.txt']
const appIdIndex = pathParts.indexOf('files') + 1;
pathParts.splice(appIdIndex + 1, 0, 'metadata');
url.pathname = pathParts.join('/');
try {
await request({
method: 'GET',
url: url.toString(),
});
fail('should have thrown');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
});

it('should block all downloads when all flags are false', async () => {
await reconfigureServer({
fileDownload: {
enableForAnonymousUser: false,
enableForAuthenticatedUser: false,
enableForPublic: false,
},
});
const file = await uploadTestFile();
const request = require('../lib/request');
try {
await request({
method: 'GET',
url: file.url,
});
fail('should have thrown');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
});

it('should allow maintenance key to bypass download restrictions', async () => {
await reconfigureServer({
fileDownload: {
enableForAnonymousUser: false,
enableForAuthenticatedUser: false,
enableForPublic: false,
},
});
const file = await uploadTestFile();
const request = require('../lib/request');
const res = await request({
headers: {
'X-Parse-Maintenance-Key': 'testing',
},
method: 'GET',
url: file.url,
});
expect(res.status).toBe(200);
});

it('should allow maintenance key to bypass upload restrictions', async () => {
await reconfigureServer({
fileUpload: {
enableForAnonymousUser: false,
enableForAuthenticatedUser: false,
enableForPublic: false,
},
});
const request = require('../lib/request');
const res = await request({
headers: {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-Maintenance-Key': 'testing',
},
method: 'POST',
url: 'http://localhost:8378/1/files/test.txt',
body: 'hello world',
});
expect(res.data.url).toBeDefined();
});
});
});
35 changes: 35 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { version } from '../package.json';
import {
AccountLockoutOptions,
DatabaseOptions,
FileDownloadOptions,
FileUploadOptions,
IdempotencyOptions,
LiveQueryOptions,
Expand Down Expand Up @@ -130,6 +131,7 @@ export class Config {
allowHeaders,
idempotencyOptions,
fileUpload,
fileDownload,
pages,
security,
enforcePrivateUsers,
Expand Down Expand Up @@ -157,6 +159,11 @@ export class Config {
this.validateAccountLockoutPolicy(accountLockout);
this.validatePasswordPolicy(passwordPolicy);
this.validateFileUploadOptions(fileUpload);
if (fileDownload == null) {
fileDownload = {};
arguments[0].fileDownload = fileDownload;
}
this.validateFileDownloadOptions(fileDownload);

if (typeof revokeSessionOnPasswordReset !== 'boolean') {
throw 'revokeSessionOnPasswordReset must be a boolean value';
Expand Down Expand Up @@ -586,6 +593,34 @@ export class Config {
}
}

static validateFileDownloadOptions(fileDownload) {
try {
if (fileDownload == null || typeof fileDownload !== 'object' || Array.isArray(fileDownload)) {
throw 'fileDownload must be an object value.';
}
} catch (e) {
if (e instanceof ReferenceError) {
return;
}
throw e;
}
if (fileDownload.enableForAnonymousUser === undefined) {
fileDownload.enableForAnonymousUser = FileDownloadOptions.enableForAnonymousUser.default;
} else if (typeof fileDownload.enableForAnonymousUser !== 'boolean') {
throw 'fileDownload.enableForAnonymousUser must be a boolean value.';
}
if (fileDownload.enableForPublic === undefined) {
fileDownload.enableForPublic = FileDownloadOptions.enableForPublic.default;
} else if (typeof fileDownload.enableForPublic !== 'boolean') {
throw 'fileDownload.enableForPublic must be a boolean value.';
}
if (fileDownload.enableForAuthenticatedUser === undefined) {
fileDownload.enableForAuthenticatedUser = FileDownloadOptions.enableForAuthenticatedUser.default;
} else if (typeof fileDownload.enableForAuthenticatedUser !== 'boolean') {
throw 'fileDownload.enableForAuthenticatedUser must be a boolean value.';
}
}

static validateIps(field, masterKeyIps) {
for (let ip of masterKeyIps) {
if (ip.includes('/')) {
Expand Down
Loading
Loading