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. diff --git a/spec/RouteAllowList.spec.js b/spec/RouteAllowList.spec.js new file mode 100644 index 0000000000..5af46d98e2 --- /dev/null +++ b/spec/RouteAllowList.spec.js @@ -0,0 +1,374 @@ +'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 }) + ); + }); + + 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({ + 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('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('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'], + cloud: () => { + Parse.Cloud.define('blockedFn', () => 'should not run'); + }, + }); + const request = require('../lib/request'); + 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); + } + } + }); + }); +}); diff --git a/src/Config.js b/src/Config.js index 924fce3ee8..9cc3cf970a 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 { + 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..35fa836304 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: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: { 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..1e7a5b661b 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: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 7b0abe303a..2ecc8479f1 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: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"] */ maintenanceKeyIps: ?(string[]); diff --git a/src/ParseServer.ts b/src/ParseServer.ts index edc085cd74..750c920556 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -311,7 +311,9 @@ 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 + 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( '/', new FilesRouter().expressRouter({ @@ -319,15 +321,7 @@ 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('/health', middlewares.enforceRouteAllowList, middlewares.handleParseHealth(options)); api.use( '/', @@ -338,6 +332,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..c531aec763 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'; @@ -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; @@ -241,70 +243,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 +445,126 @@ 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) { + return next(); + } + if (req.auth && (req.auth.isMaster || req.auth.isMaintenance)) { + return next(); + } + 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); + } + 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) {