From df9cce3c1ff35bd613d21c74ec1707a3dfec4c2b Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 2 Apr 2026 03:00:15 +0100 Subject: [PATCH 01/13] feat --- spec/RouteAllowList.spec.js | 247 ++++++++++++++++++++++++++++++++++++ src/Config.js | 26 ++++ src/Options/Definitions.js | 5 + src/Options/docs.js | 1 + src/Options/index.js | 2 + src/ParseServer.ts | 1 + src/middlewares.js | 31 ++++- 7 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 spec/RouteAllowList.spec.js diff --git a/spec/RouteAllowList.spec.js b/spec/RouteAllowList.spec.js new file mode 100644 index 0000000000..12458b5b37 --- /dev/null +++ b/spec/RouteAllowList.spec.js @@ -0,0 +1,247 @@ +'use strict'; + +const Config = require('../lib/Config'); + +describe('routeAllowList', () => { + describe('config validation', () => { + it_id('da6e6e19-a25a-4a4f-87e9-4179ac470bb4')(it)('should accept undefined (feature inactive)', async () => { + await reconfigureServer({ routeAllowList: undefined }); + expect(Config.get(Parse.applicationId).routeAllowList).toBeUndefined(); + }); + + it_id('ae221b65-c0e5-4564-bed3-08e73c07a872')(it)('should accept an empty array', async () => { + await reconfigureServer({ routeAllowList: [] }); + expect(Config.get(Parse.applicationId).routeAllowList).toEqual([]); + }); + + it_id('4d48aa24-2bc9-48af-9b59-d558c38a1173')(it)('should accept valid regex patterns', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore', 'classes/Chat.*', 'functions/.*'] }); + expect(Config.get(Parse.applicationId).routeAllowList).toEqual(['classes/GameScore', 'classes/Chat.*', 'functions/.*']); + }); + + it_id('136c091e-77e4-4c19-a1dc-a644ce2239eb')(it)('should reject non-array values', async () => { + for (const value of ['string', 123, true, {}]) { + await expectAsync(reconfigureServer({ routeAllowList: value })).toBeRejected(); + } + }); + + it_id('7f30d08d-c9db-4a35-bcc0-11cae45f106b')(it)('should reject arrays with non-string elements', async () => { + await expectAsync(reconfigureServer({ routeAllowList: [123] })).toBeRejected(); + await expectAsync(reconfigureServer({ routeAllowList: [null] })).toBeRejected(); + await expectAsync(reconfigureServer({ routeAllowList: [{}] })).toBeRejected(); + }); + + it_id('528d3457-b0d9-4f3f-8ff7-e3b9a24a6d3a')(it)('should reject invalid regex patterns', async () => { + await expectAsync(reconfigureServer({ routeAllowList: ['classes/[invalid'] })).toBeRejected(); + }); + + it_id('94ba256a-a84c-4b29-8c1e-d65bb5100da3')(it)('should compile regex patterns and cache them', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore', 'users'] }); + const config = Config.get(Parse.applicationId); + expect(config._routeAllowListRegex).toBeDefined(); + expect(config._routeAllowListRegex.length).toBe(2); + expect(config._routeAllowListRegex[0]).toEqual(jasmine.any(RegExp)); + expect(config._routeAllowListRegex[0].test('classes/GameScore')).toBe(true); + expect(config._routeAllowListRegex[0].test('classes/Other')).toBe(false); + expect(config._routeAllowListRegex[1].test('users')).toBe(true); + }); + }); + + describe('middleware', () => { + it_id('d9fb2eea-7508-4f68-bdbe-a0270595b4bf')(it)('should allow all requests when routeAllowList is undefined', async () => { + await reconfigureServer({ routeAllowList: undefined }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(); + const query = new Parse.Query('GameScore'); + const results = await query.find(); + expect(results.length).toBe(1); + }); + + it_id('3dd73684-e7b5-41dc-868b-31a64bdfb307')(it)('should block all external requests when routeAllowList is empty array', async () => { + await reconfigureServer({ routeAllowList: [] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await expectAsync(obj.save()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('be57f97e-8248-44b6-9d03-881a889f0416')(it)('should allow matching class routes', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(); + const query = new Parse.Query('GameScore'); + const results = await query.find(); + expect(results.length).toBe(1); + }); + + it_id('425449e4-72b1-4a91-8053-921c477fefd4')(it)('should block non-matching class routes', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const obj = new Parse.Object('Secret'); + obj.set('data', 'hidden'); + await expectAsync(obj.save()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('bb12a497-1187-4234-bdcc-2457d41823af')(it)('should support regex wildcard patterns', async () => { + await reconfigureServer({ routeAllowList: ['classes/Chat.*'] }); + const obj1 = new Parse.Object('ChatMessage'); + obj1.set('text', 'hello'); + await obj1.save(); + + const obj2 = new Parse.Object('ChatRoom'); + obj2.set('name', 'general'); + await obj2.save(); + + const obj3 = new Parse.Object('Secret'); + obj3.set('data', 'hidden'); + await expectAsync(obj3.save()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('980472ec-9004-40b7-b6dc-9184292e0bba')(it)('should enforce full-match anchoring', async () => { + await reconfigureServer({ routeAllowList: ['classes/Chat'] }); + const obj = new Parse.Object('ChatRoom'); + obj.set('name', 'general'); + await expectAsync(obj.save()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('ca6fedeb-f35f-48ab-baf5-b6379b96e864')(it)('should allow master key requests to bypass', async () => { + await reconfigureServer({ routeAllowList: [] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(null, { useMasterKey: true }); + const query = new Parse.Query('GameScore'); + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBe(1); + }); + + it_id('99bfdf7f-f80e-489d-9880-3d6c81391fd1')(it)('should allow Cloud Code internal calls to bypass', async () => { + await reconfigureServer({ + routeAllowList: ['functions/testInternal'], + cloud: () => { + Parse.Cloud.define('testInternal', async () => { + const obj = new Parse.Object('BlockedClass'); + obj.set('data', 'from-cloud'); + await obj.save(null, { useMasterKey: true }); + const query = new Parse.Query('BlockedClass'); + const results = await query.find({ useMasterKey: true }); + return { count: results.length }; + }); + }, + }); + const result = await Parse.Cloud.run('testInternal'); + expect(result.count).toBe(1); + }); + + it_id('34ea792f-1dcc-4399-adcf-d2d6cdfc8c6f')(it)('should allow non-class routes like users when matched', async () => { + await reconfigureServer({ routeAllowList: ['users', 'login'] }); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'testpass'); + await user.signUp(); + expect(user.getSessionToken()).toBeDefined(); + }); + + it_id('c3beed92-edd8-4cf1-be54-331a6dfaf077')(it)('should block non-class routes like users when not matched', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'testpass'); + await expectAsync(user.signUp()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('618ab39b-84f2-4547-aa27-fe478731c83f')(it)('should return sanitized error message by default', async () => { + await reconfigureServer({ routeAllowList: [] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + try { + await obj.save(); + fail('should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(e.message).toBe('Permission denied'); + } + }); + + it_id('51232d42-5c8a-4633-acc2-e0fbc40ea3da')(it)('should return detailed error message when sanitization is disabled', async () => { + await reconfigureServer({ routeAllowList: [], enableSanitizedErrorResponse: false }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + try { + await obj.save(); + fail('should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(e.message).toContain('routeAllowList'); + } + }); + + it_id('7146a4a8-9175-4a5c-b966-287e6121cb3e')(it)('should allow object get by ID when class pattern includes subpaths', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore.*'] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(); + const query = new Parse.Query('GameScore'); + const result = await query.get(obj.id); + expect(result.get('score')).toBe(100); + }); + + it_id('81156f55-e766-445d-b978-80b92e614696')(it)('should allow queries with where constraints (query string in URL)', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(); + const query = new Parse.Query('GameScore'); + query.equalTo('score', 100); + const results = await query.find(); + expect(results.length).toBe(1); + }); + + it_id('1160e6e5-c680-4f18-b1d0-ea5699c97eeb')(it)('should allow maintenance key requests to bypass', async () => { + await reconfigureServer({ routeAllowList: [] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(null, { useMasterKey: true }); + const request = require('../lib/request'); + const res = await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Maintenance-Key': 'testing', + }, + method: 'GET', + url: 'http://localhost:8378/1/classes/GameScore', + }); + expect(res.data.results.length).toBe(1); + }); + + it_id('9536b2c0-b11e-4f57-92e4-0093a40b6284')(it)('should match multiple patterns independently', async () => { + await reconfigureServer({ + routeAllowList: ['classes/AllowedA', 'classes/AllowedB', 'functions/.*'], + }); + + const objA = new Parse.Object('AllowedA'); + objA.set('data', 'a'); + await objA.save(); + + const objB = new Parse.Object('AllowedB'); + objB.set('data', 'b'); + await objB.save(); + + const objC = new Parse.Object('Blocked'); + objC.set('data', 'c'); + await expectAsync(objC.save()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + }); +}); diff --git a/src/Config.js b/src/Config.js index 924fce3ee8..3c1b5fa30b 100644 --- a/src/Config.js +++ b/src/Config.js @@ -100,6 +100,11 @@ export class Config { static put(serverConfiguration) { Config.validateOptions(serverConfiguration); Config.validateControllers(serverConfiguration); + if (serverConfiguration.routeAllowList) { + serverConfiguration._routeAllowListRegex = serverConfiguration.routeAllowList.map( + pattern => new RegExp('^' + pattern + '$') + ); + } Config.transformConfiguration(serverConfiguration); AppCache.put(serverConfiguration.appId, serverConfiguration); Config.setupPasswordValidator(serverConfiguration.passwordPolicy); @@ -139,6 +144,7 @@ export class Config { allowClientClassCreation, requestComplexity, liveQuery, + routeAllowList, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -183,6 +189,7 @@ export class Config { this.validateAllowClientClassCreation(allowClientClassCreation); this.validateRequestComplexity(requestComplexity); this.validateLiveQueryOptions(liveQuery); + this.validateRouteAllowList(routeAllowList); } static validateCustomPages(customPages) { @@ -728,6 +735,25 @@ export class Config { } } + static validateRouteAllowList(routeAllowList) { + if (routeAllowList === undefined || routeAllowList === null) { + return; + } + if (!Array.isArray(routeAllowList)) { + throw 'Parse Server option routeAllowList must be an array of strings.'; + } + for (const pattern of routeAllowList) { + if (typeof pattern !== 'string') { + throw `Parse Server option routeAllowList contains a non-string value.`; + } + try { + new RegExp('^' + pattern + '$'); + } catch (e) { + throw `Parse Server option routeAllowList contains an invalid regex pattern: "${pattern}".`; + } + } + } + static validateRateLimit(rateLimit) { if (!rateLimit) { return; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 0fcebf53b0..783533398f 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -560,6 +560,11 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: true, }, + routeAllowList: { + env: 'PARSE_SERVER_ROUTE_ALLOW_LIST', + help: '(Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:
- `classes/GameScore` (class CRUD)
- `classes/GameScore/abc123` (object by ID)
- `users` (user operations)
- `login` (login endpoint)
- `functions/sendEmail` (Cloud Function)
- `jobs/cleanup` (Cloud Job)
- `push` (push notifications)
- `config` (client config)
- `installations` (installations)
- `files/picture.jpg` (file operations)

Example patterns:
- `classes/ChatMessage` matches only `classes/ChatMessage`
- `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
- `functions/.*` matches all Cloud Functions

Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.', + action: parsers.arrayParser, + }, scheduledPush: { env: 'PARSE_SERVER_SCHEDULED_PUSH', help: 'Configuration for push scheduling, defaults to false.', diff --git a/src/Options/docs.js b/src/Options/docs.js index 6543125fe0..35ae7ac293 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -102,6 +102,7 @@ * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. * @property {String} restAPIKey Key for REST calls * @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. + * @property {String[]} routeAllowList (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:
- `classes/GameScore` (class CRUD)
- `classes/GameScore/abc123` (object by ID)
- `users` (user operations)
- `login` (login endpoint)
- `functions/sendEmail` (Cloud Function)
- `jobs/cleanup` (Cloud Job)
- `push` (push notifications)
- `config` (client config)
- `installations` (installations)
- `files/picture.jpg` (file operations)

Example patterns:
- `classes/ChatMessage` matches only `classes/ChatMessage`
- `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
- `functions/.*` matches all Cloud Functions

Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`. * @property {Boolean} scheduledPush Configuration for push scheduling, defaults to false. * @property {SchemaOptions} schema Defined schema * @property {SecurityOptions} security The security options to identify and report weak security settings. diff --git a/src/Options/index.js b/src/Options/index.js index 7b0abe303a..b36480de26 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -79,6 +79,8 @@ export interface ParseServerOptions { /* (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. :DEFAULT: ["127.0.0.1","::1"] */ masterKeyIps: ?(string[]); + /* (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:
- `classes/GameScore` (class CRUD)
- `classes/GameScore/abc123` (object by ID)
- `users` (user operations)
- `login` (login endpoint)
- `functions/sendEmail` (Cloud Function)
- `jobs/cleanup` (Cloud Job)
- `push` (push notifications)
- `config` (client config)
- `installations` (installations)
- `files/picture.jpg` (file operations)

Example patterns:
- `classes/ChatMessage` matches only `classes/ChatMessage`
- `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
- `functions/.*` matches all Cloud Functions

Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`. */ + routeAllowList: ?(string[]); /* (Optional) Restricts the use of maintenance key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the maintenance key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the maintenance key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the maintenance key. :DEFAULT: ["127.0.0.1","::1"] */ maintenanceKeyIps: ?(string[]); diff --git a/src/ParseServer.ts b/src/ParseServer.ts index edc085cd74..a756706319 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -338,6 +338,7 @@ class ParseServer { api.use(express.json({ type: '*/*', limit: maxUploadSize })); api.use(middlewares.allowMethodOverride); api.use(middlewares.handleParseHeaders); + api.use(middlewares.enforceRouteAllowList); api.set('query parser', 'extended'); const routes = Array.isArray(rateLimit) ? rateLimit : [rateLimit]; for (const route of routes) { diff --git a/src/middlewares.js b/src/middlewares.js index 687f3aae69..bbce129cec 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -14,7 +14,7 @@ import { pathToRegexp } from 'path-to-regexp'; import RedisStore from 'rate-limit-redis'; import { createClient } from 'redis'; import { BlockList, isIPv4 } from 'net'; -import { createSanitizedHttpError } from './Error'; +import { createSanitizedHttpError, createSanitizedError } from './Error'; export const DEFAULT_ALLOWED_HEADERS = 'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, X-Parse-Request-Id, Content-Type, Pragma, Cache-Control'; @@ -491,6 +491,35 @@ export function allowMethodOverride(req, res, next) { next(); } +export function enforceRouteAllowList(req, res, next) { + const config = req.config; + if (!config || config.routeAllowList === undefined || config.routeAllowList === null) { + return next(); + } + if (req.auth && (req.auth.isMaster || req.auth.isMaintenance)) { + return next(); + } + let path = req.url; + if (path.startsWith('/')) { + path = path.substring(1); + } + const queryIndex = path.indexOf('?'); + if (queryIndex !== -1) { + path = path.substring(0, queryIndex); + } + const regexes = config._routeAllowListRegex || []; + for (const regex of regexes) { + if (regex.test(path)) { + return next(); + } + } + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `Route not allowed by routeAllowList: ${req.method} ${path}`, + config + ); +} + export function handleParseErrors(err, req, res, next) { const log = (req.config && req.config.loggerController) || defaultLogger; if (err instanceof Parse.Error) { From 504509b60d0d5fe9edcfa31f2fcc9b8e946275ff Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:10:09 +0100 Subject: [PATCH 02/13] health --- spec/RouteAllowList.spec.js | 18 ++++++++++++++++++ src/ParseServer.ts | 23 ++++++++++++----------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/spec/RouteAllowList.spec.js b/spec/RouteAllowList.spec.js index 12458b5b37..33031b28a9 100644 --- a/spec/RouteAllowList.spec.js +++ b/spec/RouteAllowList.spec.js @@ -243,5 +243,23 @@ describe('routeAllowList', () => { jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) ); }); + + it_id('ad700243-ea26-41e7-b237-bd6b6aa99d46')(it)('should block health endpoint when not in allow list', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/health', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); }); }); diff --git a/src/ParseServer.ts b/src/ParseServer.ts index a756706319..98829db724 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -311,7 +311,8 @@ class ParseServer { //api.use("/apps", express.static(__dirname + "/public")); api.use(middlewares.allowCrossDomain(appId)); api.use(middlewares.allowDoubleForwardSlash); - // File handling needs to be before default middlewares are applied + // File handling needs to be before the default JSON body parser because file + // uploads send binary data that should not be parsed as JSON. api.use( '/', new FilesRouter().expressRouter({ @@ -319,16 +320,6 @@ class ParseServer { }) ); - api.use('/health', function (req, res) { - res.status(options.state === 'ok' ? 200 : 503); - if (options.state === 'starting') { - res.set('Retry-After', 1); - } - res.json({ - status: options.state, - }); - }); - api.use( '/', express.urlencoded({ extended: false }), @@ -340,6 +331,16 @@ class ParseServer { api.use(middlewares.handleParseHeaders); api.use(middlewares.enforceRouteAllowList); api.set('query parser', 'extended'); + + api.use('/health', function (req, res) { + res.status(options.state === 'ok' ? 200 : 503); + if (options.state === 'starting') { + res.set('Retry-After', 1); + } + res.json({ + status: options.state, + }); + }); const routes = Array.isArray(rateLimit) ? rateLimit : [rateLimit]; for (const route of routes) { middlewares.addRateLimit(route, options); From 9d2834fc52b4df77382f64a990de7c25ca244f88 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:34:00 +0100 Subject: [PATCH 03/13] tests --- spec/RouteAllowList.spec.js | 333 ++++++++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) diff --git a/spec/RouteAllowList.spec.js b/spec/RouteAllowList.spec.js index 33031b28a9..8a6bfede34 100644 --- a/spec/RouteAllowList.spec.js +++ b/spec/RouteAllowList.spec.js @@ -261,5 +261,338 @@ describe('routeAllowList', () => { expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); } }); + + it_id('ac0315e3-a3b1-447d-b61b-2354d0d4bc18')(it)('should block sessions routes', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + await expectAsync( + new Parse.Query('_Session').find() + ).toBeRejectedWith(jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })); + }); + + it_id('da4120c3-7ab7-4e83-aa62-609f27ae885b')(it)('should block roles routes', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const role = new Parse.Role('TestRole', new Parse.ACL()); + await expectAsync(role.save()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('72f36878-f32e-43f7-9713-c8c09e0d182b')(it)('should block installations routes', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + await expectAsync( + new Parse.Query('_Installation').find() + ).toBeRejectedWith(jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })); + }); + + it_id('a00cc50a-380b-46ff-a254-88db6846c9ba')(it)('should block push route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/push', + body: JSON.stringify({ where: {}, data: { alert: 'test' } }), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('f6028cf7-b21f-4469-b8f0-0cb5b4091137')(it)('should block schemas routes', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/schemas', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('28f8c930-3105-41b5-b5ea-06c3db9f0f59')(it)('should block config route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/config', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('eda6b96a-b6cf-4b33-8d1b-09164fc5ba8e')(it)('should block cloud functions route', async () => { + await reconfigureServer({ + routeAllowList: ['classes/GameScore'], + cloud: () => { + Parse.Cloud.define('blockedFn', () => 'should not run'); + }, + }); + await expectAsync(Parse.Cloud.run('blockedFn')).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('95b18f73-9dde-4490-8d32-e5e3dab5fe55')(it)('should block jobs route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/jobs', + body: JSON.stringify({}), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('5b6d4d4c-8586-451b-924d-9bc5dfdcd6e3')(it)('should block batch route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ requests: [] }), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('15b9499b-30e9-40b7-a11e-b23b94afd46a')(it)('should block events route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/events/AppOpened', + body: JSON.stringify({}), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('b8fb05d5-cb02-4177-932a-a3216c00752f')(it)('should block serverInfo route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/serverInfo', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('f1e46758-d8d6-41f3-a12d-816ed2db378c')(it)('should block aggregate route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/aggregate/GameScore', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('27076b00-7b83-491b-b9ca-4c17fb792f83')(it)('should block push_audiences route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/push_audiences', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('00c686f5-80d1-4820-b99a-336a969e057f')(it)('should block security route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/security', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('383a1983-4105-4227-be58-715a5440045f')(it)('should block hooks routes', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/hooks/functions', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('7be7c4ac-0105-482e-b277-277a1501fa1b')(it)('should block cloud_code routes', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/cloud_code/jobs', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('89de4fc5-33d9-4243-b054-243394f5ae15')(it)('should block scriptlog route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/scriptlog', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('82ecee96-3c63-4f48-a69e-7daee345b0e4')(it)('should block purge route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'DELETE', + url: 'http://localhost:8378/1/purge/GameScore', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('9cddbf7f-021e-4f67-9a44-2649e41459cf')(it)('should block graphql-config route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/graphql-config', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('92b4d263-b76d-4ffa-9d00-2f49a8bb4238')(it)('should block validate_purchase route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/validate_purchase', + body: JSON.stringify({}), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); }); }); From 6e8658bacf8d9a50fc6bdc8e4dbb4c87d21a667a Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:34:07 +0100 Subject: [PATCH 04/13] Update README.md --- README.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/README.md b/README.md index d069817a1a..15e84638f2 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who - [Basic Options](#basic-options) - [Client Key Options](#client-key-options) - [Access Scopes](#access-scopes) + - [Route Allow List](#route-allow-list) + - [Covered Routes](#covered-routes) - [Email Verification and Password Reset](#email-verification-and-password-reset) - [Password and Account Policy](#password-and-account-policy) - [Custom Routes](#custom-routes) @@ -309,6 +311,89 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo > [!NOTE] > In Cloud Code, both `masterKey` and `readOnlyMasterKey` set `request.master` to `true`. To distinguish between them, check `request.isReadOnly`. For example, use `request.master && !request.isReadOnly` to ensure full master key access. +## Route Allow List + +The `routeAllowList` option restricts which API routes are accessible to external clients. When set, all external requests are denied by default unless the route matches one of the configured regex patterns. This is useful for apps where all logic runs in Cloud Code and clients should not access the API directly. + +Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected. Master key and maintenance key requests bypass the restriction. + +```js +const server = ParseServer({ + ...otherOptions, + routeAllowList: [ + 'classes/ChatMessage', + 'classes/Public.*', + 'users', + 'login', + 'functions/getMenu', + 'health', + ], +}); +``` + +Each entry is a regex pattern matched against the normalized route identifier. Patterns are auto-anchored with `^` and `$` for full-match semantics. For example, `classes/Chat` matches only `classes/Chat`, not `classes/ChatRoom`. Use `classes/Chat.*` to match both. + +Setting an empty array `[]` blocks all external non-master-key requests (full lockdown). Not setting the option preserves current behavior (all routes accessible). + +### Covered Routes + +The following table lists all route groups covered by `routeAllowList` with examples of how to allow them. + +| Route group | Example route identifiers | Allow pattern | +| --- | --- | --- | +| **Data** | | | +| Classes | `classes/[className]`, `classes/[className]/[objectId]` | `classes/[className].*` | +| Aggregate | `aggregate/[className]` | `aggregate/.*` | +| Batch | `batch` | `batch` | +| Purge | `purge/[className]` | `purge/.*` | +| | | | +| **System Classes** | | | +| Users | `users`, `users/me`, `users/[objectId]` | `users.*` | +| Sessions | `sessions`, `sessions/me`, `sessions/[objectId]` | `sessions.*` | +| Installations | `installations`, `installations/[objectId]` | `installations.*` | +| Roles | `roles`, `roles/[objectId]` | `roles.*` | +| | | | +| **Auth** | | | +| Login | `login`, `loginAs` | `login.*` | +| Logout | `logout` | `logout` | +| Upgrade session | `upgradeToRevocableSession` | `upgradeToRevocableSession` | +| Auth challenge | `challenge` | `challenge` | +| Email verification | `verificationEmailRequest` | `verificationEmailRequest` | +| Password verification | `verifyPassword` | `verifyPassword` | +| Password reset | `requestPasswordReset` | `requestPasswordReset` | +| | | | +| **Cloud Code** | | | +| Cloud Functions | `functions/[functionName]` | `functions/.*` | +| Cloud Jobs (trigger) | `jobs`, `jobs/[jobName]` | `jobs.*` | +| Cloud Jobs (schedule) | `cloud_code/jobs`, `cloud_code/jobs/data`, `cloud_code/jobs/[objectId]` | `cloud_code/.*` | +| Hooks | `hooks/functions`, `hooks/triggers`, `hooks/functions/[functionName]`, `hooks/triggers/[className]/[triggerName]` | `hooks/.*` | +| | | | +| **Push** | | | +| Push | `push` | `push` | +| Push audiences | `push_audiences`, `push_audiences/[objectId]` | `push_audiences.*` | +| | | | +| **Schema** | | | +| Schemas | `schemas`, `schemas/[className]` | `schemas.*` | +| | | | +| **Config** | | | +| Config | `config` | `config` | +| GraphQL config | `graphql-config` | `graphql-config` | +| | | | +| **Analytics** | | | +| Analytics | `events/AppOpened`, `events/[eventName]` | `events/.*` | +| | | | +| **Server** | | | +| Health | `health` | `health` | +| Server info | `serverInfo` | `serverInfo` | +| Security | `security` | `security` | +| Logs | `scriptlog` | `scriptlog` | +| | | | +| **Legacy** | | | +| 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. + ## Email Verification and Password Reset Verifying user email addresses and enabling password reset via email requires an email adapter. There are many email adapters provided and maintained by the community. The following is an example configuration with an example email adapter. See the [Parse Server Options][server-options] for more details and a full list of available options. From 1e2277a4801fb501afba4d2e2acf59ee75e1d940 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:38:47 +0100 Subject: [PATCH 05/13] lint --- src/Config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config.js b/src/Config.js index 3c1b5fa30b..9cc3cf970a 100644 --- a/src/Config.js +++ b/src/Config.js @@ -748,7 +748,7 @@ export class Config { } try { new RegExp('^' + pattern + '$'); - } catch (e) { + } catch { throw `Parse Server option routeAllowList contains an invalid regex pattern: "${pattern}".`; } } From 16bf989a41369dfa5cdf621de5c27e9583488152 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:44:48 +0100 Subject: [PATCH 06/13] ul --- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 783533398f..18252c877d 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -562,7 +562,7 @@ module.exports.ParseServerOptions = { }, routeAllowList: { env: 'PARSE_SERVER_ROUTE_ALLOW_LIST', - help: '(Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:
- `classes/GameScore` (class CRUD)
- `classes/GameScore/abc123` (object by ID)
- `users` (user operations)
- `login` (login endpoint)
- `functions/sendEmail` (Cloud Function)
- `jobs/cleanup` (Cloud Job)
- `push` (push notifications)
- `config` (client config)
- `installations` (installations)
- `files/picture.jpg` (file operations)

Example patterns:
- `classes/ChatMessage` matches only `classes/ChatMessage`
- `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
- `functions/.*` matches all Cloud Functions

Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.', + help: '(Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.', action: parsers.arrayParser, }, scheduledPush: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 35ae7ac293..840976901f 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -102,7 +102,7 @@ * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. * @property {String} restAPIKey Key for REST calls * @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. - * @property {String[]} routeAllowList (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:
- `classes/GameScore` (class CRUD)
- `classes/GameScore/abc123` (object by ID)
- `users` (user operations)
- `login` (login endpoint)
- `functions/sendEmail` (Cloud Function)
- `jobs/cleanup` (Cloud Job)
- `push` (push notifications)
- `config` (client config)
- `installations` (installations)
- `files/picture.jpg` (file operations)

Example patterns:
- `classes/ChatMessage` matches only `classes/ChatMessage`
- `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
- `functions/.*` matches all Cloud Functions

Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`. + * @property {String[]} routeAllowList (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`. * @property {Boolean} scheduledPush Configuration for push scheduling, defaults to false. * @property {SchemaOptions} schema Defined schema * @property {SecurityOptions} security The security options to identify and report weak security settings. diff --git a/src/Options/index.js b/src/Options/index.js index b36480de26..7071721032 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -79,7 +79,7 @@ export interface ParseServerOptions { /* (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. :DEFAULT: ["127.0.0.1","::1"] */ masterKeyIps: ?(string[]); - /* (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:
- `classes/GameScore` (class CRUD)
- `classes/GameScore/abc123` (object by ID)
- `users` (user operations)
- `login` (login endpoint)
- `functions/sendEmail` (Cloud Function)
- `jobs/cleanup` (Cloud Job)
- `push` (push notifications)
- `config` (client config)
- `installations` (installations)
- `files/picture.jpg` (file operations)

Example patterns:
- `classes/ChatMessage` matches only `classes/ChatMessage`
- `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
- `functions/.*` matches all Cloud Functions

Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`. */ + /* (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`. */ routeAllowList: ?(string[]); /* (Optional) Restricts the use of maintenance key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the maintenance key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the maintenance key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the maintenance key. :DEFAULT: ["127.0.0.1","::1"] */ From dde1a23ca0d925fc8c46ec97f6086545a40183d9 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:30:12 +0100 Subject: [PATCH 07/13] include health endpoint --- spec/RouteAllowList.spec.js | 4 - src/ParseServer.ts | 13 +-- src/middlewares.js | 152 ++++++++++++++++++++++-------------- 3 files changed, 96 insertions(+), 73 deletions(-) diff --git a/spec/RouteAllowList.spec.js b/spec/RouteAllowList.spec.js index 8a6bfede34..2ddf5f65f3 100644 --- a/spec/RouteAllowList.spec.js +++ b/spec/RouteAllowList.spec.js @@ -249,10 +249,6 @@ describe('routeAllowList', () => { const request = require('../lib/request'); try { await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, method: 'GET', url: 'http://localhost:8378/1/health', }); diff --git a/src/ParseServer.ts b/src/ParseServer.ts index 98829db724..750c920556 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -311,6 +311,7 @@ class ParseServer { //api.use("/apps", express.static(__dirname + "/public")); api.use(middlewares.allowCrossDomain(appId)); api.use(middlewares.allowDoubleForwardSlash); + api.use(middlewares.handleParseAuth(appId)); // File handling needs to be before the default JSON body parser because file // uploads send binary data that should not be parsed as JSON. api.use( @@ -320,6 +321,8 @@ class ParseServer { }) ); + api.use('/health', middlewares.enforceRouteAllowList, middlewares.handleParseHealth(options)); + api.use( '/', express.urlencoded({ extended: false }), @@ -331,16 +334,6 @@ class ParseServer { api.use(middlewares.handleParseHeaders); api.use(middlewares.enforceRouteAllowList); api.set('query parser', 'extended'); - - api.use('/health', function (req, res) { - res.status(options.state === 'ok' ? 200 : 503); - if (options.state === 'starting') { - res.set('Retry-After', 1); - } - res.json({ - status: options.state, - }); - }); const routes = Array.isArray(rateLimit) ? rateLimit : [rateLimit]; for (const route of routes) { middlewares.addRateLimit(route, options); diff --git a/src/middlewares.js b/src/middlewares.js index bbce129cec..522619069a 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -241,70 +241,22 @@ export async function handleParseHeaders(req, res, next) { req.config.ip = clientIp; req.info = info; - const isMaintenance = - req.config.maintenanceKey && info.maintenanceKey === req.config.maintenanceKey; - if (isMaintenance) { - if (checkIp(clientIp, req.config.maintenanceKeyIps || [], req.config.maintenanceKeyIpsStore)) { - req.auth = new auth.Auth({ - config: req.config, - installationId: info.installationId, - isMaintenance: true, - }); - next(); - return; - } - const log = req.config?.loggerController || defaultLogger; - log.error( - `Request using maintenance key rejected as the request IP address '${clientIp}' is not set in Parse Server option 'maintenanceKeyIps'.` - ); - } - - const masterKey = await req.config.loadMasterKey(); - let isMaster = info.masterKey === masterKey; - - if (isMaster && !checkIp(clientIp, req.config.masterKeyIps || [], req.config.masterKeyIpsStore)) { - const log = req.config?.loggerController || defaultLogger; - log.error( - `Request using master key rejected as the request IP address '${clientIp}' is not set in Parse Server option 'masterKeyIps'.` - ); - isMaster = false; - const error = new Error(); - error.status = 403; - error.message = `unauthorized`; - throw error; - } - - if (isMaster) { - req.auth = new auth.Auth({ + // Skip key detection if already resolved by handleParseAuth (header-based). + // Only resolve here for body-based _MasterKey (info.masterKey may come from body). + if (!req.auth || (!req.auth.isMaster && !req.auth.isMaintenance)) { + const resolved = await resolveKeyAuth({ config: req.config, + keyValue: info.masterKey, + maintenanceKeyValue: info.maintenanceKey, installationId: info.installationId, - isMaster: true, + clientIp, }); - return handleRateLimit(req, res, next); + if (resolved) { + req.auth = resolved; + } } - var isReadOnlyMaster = info.masterKey === req.config.readOnlyMasterKey; - if ( - typeof req.config.readOnlyMasterKey != 'undefined' && - req.config.readOnlyMasterKey && - isReadOnlyMaster - ) { - if (!checkIp(clientIp, req.config.readOnlyMasterKeyIps || [], req.config.readOnlyMasterKeyIpsStore)) { - const log = req.config?.loggerController || defaultLogger; - log.error( - `Request using read-only master key rejected as the request IP address '${clientIp}' is not set in Parse Server option 'readOnlyMasterKeyIps'.` - ); - const error = new Error(); - error.status = 403; - error.message = 'unauthorized'; - throw error; - } - req.auth = new auth.Auth({ - config: req.config, - installationId: info.installationId, - isMaster: true, - isReadOnly: true, - }); + if (req.auth && (req.auth.isMaster || req.auth.isMaintenance)) { return handleRateLimit(req, res, next); } @@ -491,6 +443,88 @@ export function allowMethodOverride(req, res, next) { next(); } +async function resolveKeyAuth({ config, keyValue, maintenanceKeyValue, installationId, clientIp }) { + if (maintenanceKeyValue && maintenanceKeyValue === config.maintenanceKey) { + if (checkIp(clientIp, config.maintenanceKeyIps || [], config.maintenanceKeyIpsStore)) { + return new auth.Auth({ config, installationId, isMaintenance: true }); + } + const log = config.loggerController || defaultLogger; + log.error( + `Request using maintenance key rejected as the request IP address '${clientIp}' is not set in Parse Server option 'maintenanceKeyIps'.` + ); + } + const masterKey = await config.loadMasterKey(); + if (keyValue === masterKey) { + if (checkIp(clientIp, config.masterKeyIps || [], config.masterKeyIpsStore)) { + return new auth.Auth({ config, installationId, isMaster: true }); + } + const log = config.loggerController || defaultLogger; + log.error( + `Request using master key rejected as the request IP address '${clientIp}' is not set in Parse Server option 'masterKeyIps'.` + ); + const error = new Error(); + error.status = 403; + error.message = 'unauthorized'; + throw error; + } + if ( + keyValue && + typeof config.readOnlyMasterKey !== 'undefined' && + config.readOnlyMasterKey && + keyValue === config.readOnlyMasterKey + ) { + if (checkIp(clientIp, config.readOnlyMasterKeyIps || [], config.readOnlyMasterKeyIpsStore)) { + return new auth.Auth({ config, installationId, isMaster: true, isReadOnly: true }); + } + const log = config.loggerController || defaultLogger; + log.error( + `Request using read-only master key rejected as the request IP address '${clientIp}' is not set in Parse Server option 'readOnlyMasterKeyIps'.` + ); + const error = new Error(); + error.status = 403; + error.message = 'unauthorized'; + throw error; + } + return null; +} + +export function handleParseAuth(appId) { + return async (req, res, next) => { + const mount = getMountForRequest(req); + const config = Config.get(appId, mount); + if (!config) { + return next(); + } + req.config = config; + const clientIp = getClientIp(req); + req.config.ip = clientIp; + await config.loadKeys(); + const resolved = await resolveKeyAuth({ + config, + keyValue: req.get('X-Parse-Master-Key') || null, + maintenanceKeyValue: req.get('X-Parse-Maintenance-Key') || null, + installationId: req.get('X-Parse-Installation-Id') || 'cloud', + clientIp, + }); + if (resolved) { + req.auth = resolved; + } + return next(); + }; +} + +export function handleParseHealth(options) { + return (req, res) => { + res.status(options.state === 'ok' ? 200 : 503); + if (options.state === 'starting') { + res.set('Retry-After', 1); + } + res.json({ + status: options.state, + }); + }; +} + export function enforceRouteAllowList(req, res, next) { const config = req.config; if (!config || config.routeAllowList === undefined || config.routeAllowList === null) { From 68086670b25d3ee8f247d7d99e00280d761bf37e Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:44:30 +0100 Subject: [PATCH 08/13] fix duplicate config loading --- src/middlewares.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/middlewares.js b/src/middlewares.js index 522619069a..1e586bbd59 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -224,7 +224,7 @@ export async function handleParseHeaders(req, res, next) { } const clientIp = getClientIp(req); - const config = Config.get(info.appId, mount); + const config = req.config || Config.get(info.appId, mount); if (config.state && config.state !== 'ok') { res.status(500); res.json({ @@ -233,7 +233,9 @@ export async function handleParseHeaders(req, res, next) { }); return; } - await config.loadKeys(); + if (!req.config) { + await config.loadKeys(); + } info.app = AppCache.get(info.appId); req.config = config; From 3b03955c36c3584196c89a618fb611bd04f0af73 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:35:20 +0100 Subject: [PATCH 09/13] health --- spec/RouteAllowList.spec.js | 10 ++++++++++ src/middlewares.js | 11 ++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/spec/RouteAllowList.spec.js b/spec/RouteAllowList.spec.js index 2ddf5f65f3..cae66c97e6 100644 --- a/spec/RouteAllowList.spec.js +++ b/spec/RouteAllowList.spec.js @@ -258,6 +258,16 @@ describe('routeAllowList', () => { } }); + it_id('b59dd736-029d-4769-b69d-ac3aed6e4c3f')(it)('should allow health endpoint when in allow list', async () => { + await reconfigureServer({ routeAllowList: ['health'] }); + const request = require('../lib/request'); + const res = await request({ + method: 'GET', + url: 'http://localhost:8378/1/health', + }); + expect(res.data.status).toBe('ok'); + }); + it_id('ac0315e3-a3b1-447d-b61b-2354d0d4bc18')(it)('should block sessions routes', async () => { await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); await expectAsync( diff --git a/src/middlewares.js b/src/middlewares.js index 1e586bbd59..c531aec763 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -535,10 +535,19 @@ export function enforceRouteAllowList(req, res, next) { if (req.auth && (req.auth.isMaster || req.auth.isMaintenance)) { return next(); } - let path = req.url; + let path = req.originalUrl; + if (config.mount) { + const mountPath = new URL(config.mount).pathname; + if (path.startsWith(mountPath)) { + path = path.substring(mountPath.length); + } + } if (path.startsWith('/')) { path = path.substring(1); } + if (path.endsWith('/')) { + path = path.substring(0, path.length - 1); + } const queryIndex = path.indexOf('?'); if (queryIndex !== -1) { path = path.substring(0, queryIndex); From 2720e75f60edc87b04d5b79ec9bb3e246da1d60b Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:51:46 +0100 Subject: [PATCH 10/13] tests --- spec/RouteAllowList.spec.js | 184 ++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/spec/RouteAllowList.spec.js b/spec/RouteAllowList.spec.js index cae66c97e6..f747135749 100644 --- a/spec/RouteAllowList.spec.js +++ b/spec/RouteAllowList.spec.js @@ -600,5 +600,189 @@ describe('routeAllowList', () => { expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); } }); + it_id('60466f80-27af-456c-a05d-8f5ceaf95451')(it)('should allow read-only master key requests to bypass', async () => { + await reconfigureServer({ routeAllowList: [] }); + const request = require('../lib/request'); + const res = await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'read-only-test', + }, + method: 'GET', + url: 'http://localhost:8378/1/classes/GameScore', + }); + expect(res.data.results).toEqual([]); + }); + + it_id('4fe57cc2-f104-491c-843b-64afc11c6fa3')(it)('should block all routes when routeAllowList is empty array and no key provided', async () => { + await reconfigureServer({ routeAllowList: [] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/classes/GameScore', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('f3dd5622-036c-45bf-ab76-c31b59028642')(it)('should block health endpoint even when routeAllowList is empty array', async () => { + await reconfigureServer({ routeAllowList: [] }); + const request = require('../lib/request'); + try { + await request({ + method: 'GET', + url: 'http://localhost:8378/1/health', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('ed3797f6-38ee-4bf0-806f-a7242ae14b5c')(it)('should block logout route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/logout', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('2d7ce7cd-7d61-418f-8255-451304e18f11')(it)('should block loginAs route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/loginAs', + body: JSON.stringify({}), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('808c7f7e-3918-4851-915c-205b1f807965')(it)('should block upgradeToRevocableSession route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/upgradeToRevocableSession', + body: JSON.stringify({}), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('ad06367e-b220-4f9f-9ee6-8756bea36937')(it)('should block verificationEmailRequest route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/verificationEmailRequest', + body: JSON.stringify({}), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('a14df8c8-a09a-47fa-a208-74f8e429f060')(it)('should block verifyPassword route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/verifyPassword', + body: JSON.stringify({}), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('acb37217-ab57-42f5-86b3-f81c61b28003')(it)('should block requestPasswordReset route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/requestPasswordReset', + body: JSON.stringify({}), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('4b67e9cc-8068-4848-a536-229818d0c0ed')(it)('should block challenge route', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({}), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); }); }); From 0abca4e3b76256657b8b165fc14929ba2bb594ec Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:54:20 +0100 Subject: [PATCH 11/13] compact tests --- spec/RouteAllowList.spec.js | 508 ++++-------------------------------- 1 file changed, 47 insertions(+), 461 deletions(-) diff --git a/spec/RouteAllowList.spec.js b/spec/RouteAllowList.spec.js index f747135749..b9844a9234 100644 --- a/spec/RouteAllowList.spec.js +++ b/spec/RouteAllowList.spec.js @@ -268,338 +268,63 @@ describe('routeAllowList', () => { expect(res.data.status).toBe('ok'); }); - it_id('ac0315e3-a3b1-447d-b61b-2354d0d4bc18')(it)('should block sessions routes', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - await expectAsync( - new Parse.Query('_Session').find() - ).toBeRejectedWith(jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })); - }); - - it_id('da4120c3-7ab7-4e83-aa62-609f27ae885b')(it)('should block roles routes', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const role = new Parse.Role('TestRole', new Parse.ACL()); - await expectAsync(role.save()).toBeRejectedWith( - jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) - ); - }); - - it_id('72f36878-f32e-43f7-9713-c8c09e0d182b')(it)('should block installations routes', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - await expectAsync( - new Parse.Query('_Installation').find() - ).toBeRejectedWith(jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })); - }); - - it_id('a00cc50a-380b-46ff-a254-88db6846c9ba')(it)('should block push route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/push', - body: JSON.stringify({ where: {}, data: { alert: 'test' } }), - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('f6028cf7-b21f-4469-b8f0-0cb5b4091137')(it)('should block schemas routes', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'GET', - url: 'http://localhost:8378/1/schemas', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('28f8c930-3105-41b5-b5ea-06c3db9f0f59')(it)('should block config route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'GET', - url: 'http://localhost:8378/1/config', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('eda6b96a-b6cf-4b33-8d1b-09164fc5ba8e')(it)('should block cloud functions route', async () => { + it_id('229cab22-dad3-4d08-8de5-64d813658596')(it)('should block all route groups when not in allow list', async () => { await reconfigureServer({ routeAllowList: ['classes/GameScore'], cloud: () => { Parse.Cloud.define('blockedFn', () => 'should not run'); }, }); - await expectAsync(Parse.Cloud.run('blockedFn')).toBeRejectedWith( - jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) - ); - }); - - it_id('95b18f73-9dde-4490-8d32-e5e3dab5fe55')(it)('should block jobs route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/jobs', - body: JSON.stringify({}), - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('5b6d4d4c-8586-451b-924d-9bc5dfdcd6e3')(it)('should block batch route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/batch', - body: JSON.stringify({ requests: [] }), - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('15b9499b-30e9-40b7-a11e-b23b94afd46a')(it)('should block events route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/events/AppOpened', - body: JSON.stringify({}), - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('b8fb05d5-cb02-4177-932a-a3216c00752f')(it)('should block serverInfo route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'GET', - url: 'http://localhost:8378/1/serverInfo', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('f1e46758-d8d6-41f3-a12d-816ed2db378c')(it)('should block aggregate route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'GET', - url: 'http://localhost:8378/1/aggregate/GameScore', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('27076b00-7b83-491b-b9ca-4c17fb792f83')(it)('should block push_audiences route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'GET', - url: 'http://localhost:8378/1/push_audiences', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('00c686f5-80d1-4820-b99a-336a969e057f')(it)('should block security route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'GET', - url: 'http://localhost:8378/1/security', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('383a1983-4105-4227-be58-715a5440045f')(it)('should block hooks routes', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'GET', - url: 'http://localhost:8378/1/hooks/functions', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('7be7c4ac-0105-482e-b277-277a1501fa1b')(it)('should block cloud_code routes', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'GET', - url: 'http://localhost:8378/1/cloud_code/jobs', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('89de4fc5-33d9-4243-b054-243394f5ae15')(it)('should block scriptlog route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'GET', - url: 'http://localhost:8378/1/scriptlog', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('82ecee96-3c63-4f48-a69e-7daee345b0e4')(it)('should block purge route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'DELETE', - url: 'http://localhost:8378/1/purge/GameScore', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('9cddbf7f-021e-4f67-9a44-2649e41459cf')(it)('should block graphql-config route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); const request = require('../lib/request'); - try { - await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'GET', - url: 'http://localhost:8378/1/graphql-config', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + const routes = [ + { method: 'GET', path: 'sessions' }, + { method: 'GET', path: 'roles' }, + { method: 'GET', path: 'installations' }, + { method: 'POST', path: 'push' }, + { method: 'GET', path: 'schemas' }, + { method: 'GET', path: 'config' }, + { method: 'POST', path: 'jobs' }, + { method: 'POST', path: 'batch' }, + { method: 'POST', path: 'events/AppOpened' }, + { method: 'GET', path: 'serverInfo' }, + { method: 'GET', path: 'aggregate/GameScore' }, + { method: 'GET', path: 'push_audiences' }, + { method: 'GET', path: 'security' }, + { method: 'GET', path: 'hooks/functions' }, + { method: 'GET', path: 'cloud_code/jobs' }, + { method: 'GET', path: 'scriptlog' }, + { method: 'DELETE', path: 'purge/GameScore' }, + { method: 'GET', path: 'graphql-config' }, + { method: 'POST', path: 'validate_purchase' }, + { method: 'POST', path: 'logout' }, + { method: 'POST', path: 'loginAs' }, + { method: 'POST', path: 'upgradeToRevocableSession' }, + { method: 'POST', path: 'verificationEmailRequest' }, + { method: 'POST', path: 'verifyPassword' }, + { method: 'POST', path: 'requestPasswordReset' }, + { method: 'POST', path: 'challenge' }, + { method: 'GET', path: 'health' }, + { method: 'POST', path: 'functions/blockedFn' }, + ]; + for (const route of routes) { + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: route.method, + url: `http://localhost:8378/1/${route.path}`, + body: route.method === 'POST' ? JSON.stringify({}) : undefined, + }); + fail(`should have blocked ${route.method} ${route.path}`); + } catch (e) { + expect(e.data.code).withContext(`${route.method} ${route.path}`).toBe(Parse.Error.OPERATION_FORBIDDEN); + } } }); - it_id('92b4d263-b76d-4ffa-9d00-2f49a8bb4238')(it)('should block validate_purchase route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/validate_purchase', - body: JSON.stringify({}), - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); it_id('60466f80-27af-456c-a05d-8f5ceaf95451')(it)('should allow read-only master key requests to bypass', async () => { await reconfigureServer({ routeAllowList: [] }); const request = require('../lib/request'); @@ -645,144 +370,5 @@ describe('routeAllowList', () => { expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); } }); - - it_id('ed3797f6-38ee-4bf0-806f-a7242ae14b5c')(it)('should block logout route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/logout', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('2d7ce7cd-7d61-418f-8255-451304e18f11')(it)('should block loginAs route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/loginAs', - body: JSON.stringify({}), - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('808c7f7e-3918-4851-915c-205b1f807965')(it)('should block upgradeToRevocableSession route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/upgradeToRevocableSession', - body: JSON.stringify({}), - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('ad06367e-b220-4f9f-9ee6-8756bea36937')(it)('should block verificationEmailRequest route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/verificationEmailRequest', - body: JSON.stringify({}), - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('a14df8c8-a09a-47fa-a208-74f8e429f060')(it)('should block verifyPassword route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/verifyPassword', - body: JSON.stringify({}), - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('acb37217-ab57-42f5-86b3-f81c61b28003')(it)('should block requestPasswordReset route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/requestPasswordReset', - body: JSON.stringify({}), - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('4b67e9cc-8068-4848-a536-229818d0c0ed')(it)('should block challenge route', async () => { - await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'POST', - url: 'http://localhost:8378/1/challenge', - body: JSON.stringify({}), - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); }); }); From f5e285c72e2af6cd83e4e0a89022af0d9c9845af Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:56:03 +0100 Subject: [PATCH 12/13] reorder tests --- spec/RouteAllowList.spec.js | 92 ++++++++++++++++++------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/spec/RouteAllowList.spec.js b/spec/RouteAllowList.spec.js index b9844a9234..5af46d98e2 100644 --- a/spec/RouteAllowList.spec.js +++ b/spec/RouteAllowList.spec.js @@ -268,6 +268,52 @@ describe('routeAllowList', () => { expect(res.data.status).toBe('ok'); }); + it_id('60466f80-27af-456c-a05d-8f5ceaf95451')(it)('should allow read-only master key requests to bypass', async () => { + await reconfigureServer({ routeAllowList: [] }); + const request = require('../lib/request'); + const res = await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'read-only-test', + }, + method: 'GET', + url: 'http://localhost:8378/1/classes/GameScore', + }); + expect(res.data.results).toEqual([]); + }); + + it_id('4fe57cc2-f104-491c-843b-64afc11c6fa3')(it)('should block all routes when routeAllowList is empty array and no key provided', async () => { + await reconfigureServer({ routeAllowList: [] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/classes/GameScore', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('f3dd5622-036c-45bf-ab76-c31b59028642')(it)('should block health endpoint even when routeAllowList is empty array', async () => { + await reconfigureServer({ routeAllowList: [] }); + const request = require('../lib/request'); + try { + await request({ + method: 'GET', + url: 'http://localhost:8378/1/health', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + it_id('229cab22-dad3-4d08-8de5-64d813658596')(it)('should block all route groups when not in allow list', async () => { await reconfigureServer({ routeAllowList: ['classes/GameScore'], @@ -324,51 +370,5 @@ describe('routeAllowList', () => { } } }); - - it_id('60466f80-27af-456c-a05d-8f5ceaf95451')(it)('should allow read-only master key requests to bypass', async () => { - await reconfigureServer({ routeAllowList: [] }); - const request = require('../lib/request'); - const res = await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'read-only-test', - }, - method: 'GET', - url: 'http://localhost:8378/1/classes/GameScore', - }); - expect(res.data.results).toEqual([]); - }); - - it_id('4fe57cc2-f104-491c-843b-64afc11c6fa3')(it)('should block all routes when routeAllowList is empty array and no key provided', async () => { - await reconfigureServer({ routeAllowList: [] }); - const request = require('../lib/request'); - try { - await request({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - method: 'GET', - url: 'http://localhost:8378/1/classes/GameScore', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); - - it_id('f3dd5622-036c-45bf-ab76-c31b59028642')(it)('should block health endpoint even when routeAllowList is empty array', async () => { - await reconfigureServer({ routeAllowList: [] }); - const request = require('../lib/request'); - try { - await request({ - method: 'GET', - url: 'http://localhost:8378/1/health', - }); - fail('should have thrown'); - } catch (e) { - expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - } - }); }); }); From 5cd77198cf4f2775a03e5db4cf64e6c6709a275f Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:49:22 +0100 Subject: [PATCH 13/13] default in options doc --- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 18252c877d..35fa836304 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -562,7 +562,7 @@ module.exports.ParseServerOptions = { }, routeAllowList: { env: 'PARSE_SERVER_ROUTE_ALLOW_LIST', - help: '(Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.', + help: '(Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible.', action: parsers.arrayParser, }, scheduledPush: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 840976901f..1e7a5b661b 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -102,7 +102,7 @@ * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. * @property {String} restAPIKey Key for REST calls * @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. - * @property {String[]} routeAllowList (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`. + * @property {String[]} routeAllowList (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible. * @property {Boolean} scheduledPush Configuration for push scheduling, defaults to false. * @property {SchemaOptions} schema Defined schema * @property {SecurityOptions} security The security options to identify and report weak security settings. diff --git a/src/Options/index.js b/src/Options/index.js index 7071721032..2ecc8479f1 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -79,7 +79,7 @@ export interface ParseServerOptions { /* (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. :DEFAULT: ["127.0.0.1","::1"] */ masterKeyIps: ?(string[]); - /* (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`. */ + /* (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible. */ routeAllowList: ?(string[]); /* (Optional) Restricts the use of maintenance key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the maintenance key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the maintenance key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the maintenance key. :DEFAULT: ["127.0.0.1","::1"] */