feat: Add server option fileDownload to restrict file download (#10394)

This commit is contained in:
Manuel
2026-04-03 19:41:42 +01:00
committed by GitHub
parent 89dec6d789
commit fc117efa4d
8 changed files with 447 additions and 42 deletions
+1 -1
View File
@@ -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
+2
View File
@@ -15,6 +15,7 @@ const parsers = require('../src/Options/parsers');
const nestedOptionTypes = [
'CustomPagesOptions',
'DatabaseOptions',
'FileDownloadOptions',
'FileUploadOptions',
'IdempotencyOptions',
'Object',
@@ -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_',
+282
View File
@@ -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
View File
@@ -12,6 +12,7 @@ import { version } from '../package.json';
import {
AccountLockoutOptions,
DatabaseOptions,
FileDownloadOptions,
FileUploadOptions,
IdempotencyOptions,
LiveQueryOptions,
@@ -130,6 +131,7 @@ export class Config {
allowHeaders,
idempotencyOptions,
fileUpload,
fileDownload,
pages,
security,
enforcePrivateUsers,
@@ -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';
@@ -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('/')) {
+27
View File
@@ -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',
@@ -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: {
+8
View File
@@ -52,6 +52,7 @@
* @property {Boolean} enforcePrivateUsers Set to true if new users should be created without public read and write access.
* @property {Boolean} expireInactiveSessions Sets whether we should expire the inactive sessions, defaults to true. If false, all new sessions are created with no expiration date.
* @property {Boolean} extendSessionOnUse Whether Parse Server should automatically extend a valid session by the sessionLength. In order to reduce the number of session updates in the database, a session will only be extended when a request is received after at least half of the current session's lifetime has passed.
* @property {FileDownloadOptions} fileDownload Options for file downloads
* @property {String} fileKey Key for your files
* @property {Adapter<FilesAdapter>} filesAdapter Adapter module for the files sub-system
* @property {FileUploadOptions} fileUpload Options for file uploads
@@ -259,6 +260,13 @@
* @property {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.<br><br>It is recommended to only allow the file extensions that your app actually needs, rather than relying on blocking dangerous extensions. This allowlist approach is more secure because new dangerous file extensions may emerge that are not covered by the default blocklist.<br><br>The default blocks the most common file extensions that are known to be rendered as active content by web browsers, such as HTML, SVG, and XML files, which may be used by an attacker to compromise the session token of another user via accessing the browser's local storage. The blocked extensions are: `html`, `htm`, `shtml`, `xhtml`, `xhtml+xml`, `xht`, `svg`, `svgz`, `svg+xml`, `xml`, `xsl`, `xslt`, `xslt+xml`, `xsd`, `rng`, `rdf`, `rdf+xml`, `owl`, `mathml`, `mathml+xml`.<br><br>Defaults to `["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)"]`.
*/
/**
* @interface FileDownloadOptions
* @property {Boolean} enableForAnonymousUser Is true if file download should be allowed for anonymous users.
* @property {Boolean} enableForAuthenticatedUser Is true if file download should be allowed for authenticated users.
* @property {Boolean} enableForPublic Is true if file download should be allowed for anyone, regardless of user authentication.
*/
/**
* @interface LogLevel
* @property {StringLiteral} debug Debug level
+16
View File
@@ -355,6 +355,10 @@ export interface ParseServerOptions {
:ENV: PARSE_SERVER_FILE_UPLOAD_OPTIONS
:DEFAULT: {} */
fileUpload: ?FileUploadOptions;
/* Options for file downloads
:ENV: PARSE_SERVER_FILE_DOWNLOAD_OPTIONS
:DEFAULT: {} */
fileDownload: ?FileDownloadOptions;
/* Full path to your GraphQL custom schema.graphql file */
graphQLSchema: ?string;
/* Mounts the GraphQL endpoint
@@ -698,6 +702,18 @@ export interface FileUploadOptions {
allowedFileUrlDomains: ?(string[]);
}
export interface FileDownloadOptions {
/* Is true if file download should be allowed for anonymous users.
:DEFAULT: true */
enableForAnonymousUser: ?boolean;
/* Is true if file download should be allowed for authenticated users.
:DEFAULT: true */
enableForAuthenticatedUser: ?boolean;
/* Is true if file download should be allowed for anyone, regardless of user authentication.
:DEFAULT: true */
enableForPublic: ?boolean;
}
/* 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) */
export interface LogLevel {
/* Error level - highest priority */
+76 -41
View File
@@ -5,7 +5,6 @@ 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';
@@ -89,9 +88,27 @@ export const RESERVED_DIRECTORY_SEGMENTS = ['metadata'];
export class FilesRouter {
expressRouter({ maxUploadSize = '20Mb' } = {}) {
var router = express.Router();
// Lightweight info initializer so handleParseSession can resolve session tokens.
// Unlike POST/DELETE routes, GET file routes skip handleParseHeaders (which
// normally sets req.info) because those requests may not carry Parse headers.
const initInfo = (req, res, next) => {
if (!req.info) {
const sessionToken = req.get('X-Parse-Session-Token');
req.info = {
sessionToken,
installationId: req.get('X-Parse-Installation-Id'),
};
// If no session token and no auth yet (public access), set a minimal
// auth object so handleParseSession skips session resolution.
if (!sessionToken && !req.auth) {
req.auth = { isMaster: false };
}
}
next();
};
// Metadata route must come before the catch-all GET route
router.get('/files/:appId/metadata/*filepath', this.metadataHandler);
router.get('/files/:appId/*filepath', this.getHandler);
router.get('/files/:appId/metadata/*filepath', initInfo, Middlewares.handleParseSession, this.metadataHandler);
router.get('/files/:appId/*filepath', initInfo, Middlewares.handleParseSession, this.getHandler);
router.post('/files', function (req, res, next) {
next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename not provided.'));
@@ -121,22 +138,6 @@ 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.');
@@ -179,6 +180,34 @@ export class FilesRouter {
return null;
}
static _validateFileDownload(req, config) {
const isMaster = req.auth?.isMaster;
const isMaintenance = req.auth?.isMaintenance;
if (isMaster || isMaintenance) {
return;
}
const user = req.auth?.user;
const isLinked = user && Parse.AnonymousUtils.isLinked(user);
if (!config.fileDownload.enableForAnonymousUser && isLinked) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
'File download by anonymous user is disabled.'
);
}
if (!config.fileDownload.enableForAuthenticatedUser && !isLinked && user) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
'File download by authenticated user is disabled.'
);
}
if (!config.fileDownload.enableForPublic && !user) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
'File download by public is disabled.'
);
}
}
async getHandler(req, res) {
const config = Config.get(req.params.appId);
if (!config) {
@@ -188,13 +217,15 @@ export class FilesRouter {
return;
}
FilesRouter._validateFileDownload(req, config);
let filename = FilesRouter._getFilenameFromParams(req);
try {
const filesController = config.filesController;
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 fileAuth = req.auth;
const triggerResult = await triggers.maybeRunFileTrigger(
triggers.Types.beforeFind,
{ file },
@@ -344,27 +375,30 @@ export class FilesRouter {
return;
}
const config = req.config;
const user = req.auth.user;
const isMaster = req.auth.isMaster;
const isLinked = user && Parse.AnonymousUtils.isLinked(user);
if (!isMaster && !config.fileUpload.enableForAnonymousUser && isLinked) {
next(
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by anonymous user is disabled.')
);
return;
}
if (!isMaster && !config.fileUpload.enableForAuthenticatedUser && !isLinked && user) {
next(
new Parse.Error(
Parse.Error.FILE_SAVE_ERROR,
'File upload by authenticated user is disabled.'
)
);
return;
}
if (!isMaster && !config.fileUpload.enableForPublic && !user) {
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by public is disabled.'));
return;
const isMaintenance = req.auth.isMaintenance;
if (!isMaster && !isMaintenance) {
const user = req.auth.user;
const isLinked = user && Parse.AnonymousUtils.isLinked(user);
if (!config.fileUpload.enableForAnonymousUser && isLinked) {
next(
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by anonymous user is disabled.')
);
return;
}
if (!config.fileUpload.enableForAuthenticatedUser && !isLinked && user) {
next(
new Parse.Error(
Parse.Error.FILE_SAVE_ERROR,
'File upload by authenticated user is disabled.'
)
);
return;
}
if (!config.fileUpload.enableForPublic && !user) {
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by public is disabled.'));
return;
}
}
const filesController = config.filesController;
const { filename } = req.params;
@@ -760,10 +794,11 @@ export class FilesRouter {
res.json({});
return;
}
FilesRouter._validateFileDownload(req, config);
const { filesController } = config;
let filename = FilesRouter._getFilenameFromParams(req);
const file = new Parse.File(filename, { base64: '' });
const fileAuth = await FilesRouter._resolveAuth(req, config);
const fileAuth = req.auth;
const triggerResult = await triggers.maybeRunFileTrigger(
triggers.Types.beforeFind,
{ file },