Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
240 changes: 240 additions & 0 deletions spec/FileDownload.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
'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);
}
});
});
});
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
27 changes: 27 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,13 @@ module.exports.ParseServerOptions = {
action: parsers.booleanParser,
default: false,
},
fileDownload: {
env: 'PARSE_SERVER_FILE_DOWNLOAD_OPTIONS',
help: 'Options for file downloads',
action: parsers.objectParser,
type: 'FileDownloadOptions',
default: {},
},
fileKey: {
env: 'PARSE_SERVER_FILE_KEY',
help: 'Key for your files',
Expand Down Expand Up @@ -1113,6 +1120,26 @@ module.exports.FileUploadOptions = {
],
},
};
module.exports.FileDownloadOptions = {
enableForAnonymousUser: {
env: 'PARSE_SERVER_FILE_DOWNLOAD_ENABLE_FOR_ANONYMOUS_USER',
help: 'Is true if file download should be allowed for anonymous users.',
action: parsers.booleanParser,
default: true,
},
enableForAuthenticatedUser: {
env: 'PARSE_SERVER_FILE_DOWNLOAD_ENABLE_FOR_AUTHENTICATED_USER',
help: 'Is true if file download should be allowed for authenticated users.',
action: parsers.booleanParser,
default: true,
},
enableForPublic: {
env: 'PARSE_SERVER_FILE_DOWNLOAD_ENABLE_FOR_PUBLIC',
help: 'Is true if file download should be allowed for anyone, regardless of user authentication.',
action: parsers.booleanParser,
default: true,
},
};
/* The available log levels for Parse Server logging. Valid values are:<br>- `'error'` - Error level (highest priority)<br>- `'warn'` - Warning level<br>- `'info'` - Info level (default)<br>- `'verbose'` - Verbose level<br>- `'debug'` - Debug level<br>- `'silly'` - Silly level (lowest priority) */
module.exports.LogLevel = {
debug: {
Expand Down
8 changes: 8 additions & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading