From cd56c63932f5706ea9cb4040f15ffcb162953fa3 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Thu, 23 Jul 2026 15:10:49 +0200 Subject: [PATCH 1/7] fix: Apply requestContextMiddleware on directAccess requests Nested SDK ops under directAccess bypass Express, so DI injected via requestContextMiddleware was missing on Cloud hooks. Re-apply the middleware after Config.get() in ParseServerRESTController. --- spec/rest.spec.js | 32 ++++ src/Options/Definitions.js | 3 +- src/Options/docs.js | 2 +- src/ParseServerRESTController.js | 295 ++++++++++++++++++------------- 4 files changed, 207 insertions(+), 125 deletions(-) diff --git a/spec/rest.spec.js b/spec/rest.spec.js index 9416d9230e..091fb440c6 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -1758,4 +1758,36 @@ describe('rest context', () => { expect(called).toBe(true); }); + + it('should support dependency injection on nested directAccess ops', async () => { + const ParseServerRESTController = require('../lib/ParseServerRESTController') + .ParseServerRESTController; + const ParseServer = require('../lib/ParseServer').default; + const requestContextMiddleware = (req, res, next) => { + req.config.aCustomController = 'aCustomController'; + next(); + }; + await reconfigureServer({ requestContextMiddleware, directAccess: true }); + // reconfigureServer restores the HTTP RESTController; re-enable directAccess + // so nested Cloud Code SDK ops go through ParseServerRESTController. + Parse.CoreManager.setRESTController( + ParseServerRESTController( + Parse.applicationId, + ParseServer.promiseRouter({ appId: Parse.applicationId }) + ) + ); + + let nestedCalled = false; + Parse.Cloud.beforeSave('Child', request => { + expect(request.config.aCustomController).toEqual('aCustomController'); + nestedCalled = true; + }); + Parse.Cloud.afterSave('Parent', async () => { + const child = new Parse.Object('Child'); + await child.save(null, { useMasterKey: true }); + }); + + await new Parse.Object('Parent').save(null, { useMasterKey: true }); + expect(nestedCalled).toBe(true); + }); }); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 64c54561eb..be5571fc7d 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -558,7 +558,8 @@ module.exports.ParseServerOptions = { }, requestContextMiddleware: { env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE', - help: 'Options to customize the request context using inversion of control/dependency injection.', + help: + 'Options to customize the request context using inversion of control/dependency injection. Applied on Express HTTP requests and on internal directAccess requests via ParseServerRESTController (synthetic request; headers may be empty).', }, requestKeywordDenylist: { env: 'PARSE_SERVER_REQUEST_KEYWORD_DENYLIST', diff --git a/src/Options/docs.js b/src/Options/docs.js index 564a9b1718..58e991d0a4 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -102,7 +102,7 @@ * @property {String} readOnlyMasterKey The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.

It is intended strictly for internal, server-side use — for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used. * @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only 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 read-only 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 read-only 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 `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`. * @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. - * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. + * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. Applied on Express HTTP requests and on internal `directAccess` requests via `ParseServerRESTController` (synthetic request; headers may be empty). * @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. diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index 3f1c4dd4bf..23cd167e96 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -29,140 +29,189 @@ function getAuth(options = {}, config) { }); } +/** + * Apply requestContextMiddleware on a synthetic request so directAccess ops + * get the same per-request DI as Express HTTP requests. + */ +function applyRequestContextMiddleware(config) { + if (typeof config.requestContextMiddleware !== 'function') { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + let settled = false; + const done = err => { + if (settled) { + return; + } + settled = true; + if (err) { + reject(err); + } else { + resolve(); + } + }; + const req = { config, headers: {} }; + try { + const maybePromise = config.requestContextMiddleware(req, {}, done); + if (maybePromise && typeof maybePromise.then === 'function') { + maybePromise.then(() => done(), done); + } + } catch (err) { + done(err); + } + }); +} + function ParseServerRESTController(applicationId, router) { function handleRequest(method, path, data = {}, options = {}, config) { // Store the arguments, for later use if internal fails const args = arguments; + const configWasProvided = !!config; - if (!config) { - config = Config.get(applicationId); - } - const serverURL = new URL(config.serverURL); - if (path.indexOf(serverURL.pathname) === 0) { - path = path.slice(serverURL.pathname.length, path.length); - } - - if (path[0] !== '/') { - path = '/' + path; - } + return Promise.resolve() + .then(() => { + if (!configWasProvided) { + config = Config.get(applicationId); + // Fresh config from AppCache has no Express middleware mutations; + // re-apply requestContextMiddleware for DI parity with HTTP. + return applyRequestContextMiddleware(config); + } + }) + .then(() => { + const serverURL = new URL(config.serverURL); + if (path.indexOf(serverURL.pathname) === 0) { + path = path.slice(serverURL.pathname.length, path.length); + } - if (path === '/batch') { - const batch = transactionRetries => { - let initialPromise = Promise.resolve(); - if (data.transaction === true) { - initialPromise = config.database.createTransactionalSession(); + if (path[0] !== '/') { + path = '/' + path; } - return initialPromise.then(() => { - const promises = data.requests.map(request => { - return handleRequest(request.method, request.path, request.body, options, config).then( - response => { - if (options.returnStatus) { - const status = response._status; - const headers = response._headers; - delete response._status; - delete response._headers; - return { success: response, _status: status, _headers: headers }; - } - return { success: response }; - }, - error => { - return { - error: { code: error.code, error: error.message }, - }; - } - ); - }); - return Promise.all(promises) - .then(result => { - if (data.transaction === true) { - if (result.find(resultItem => typeof resultItem.error === 'object')) { - return config.database.abortTransactionalSession().then(() => { - return Promise.reject(result); - }); - } else { - return config.database.commitTransactionalSession().then(() => { + + if (path === '/batch') { + const batch = transactionRetries => { + let initialPromise = Promise.resolve(); + if (data.transaction === true) { + initialPromise = config.database.createTransactionalSession(); + } + return initialPromise.then(() => { + const promises = data.requests.map(request => { + return handleRequest( + request.method, + request.path, + request.body, + options, + config + ).then( + response => { + if (options.returnStatus) { + const status = response._status; + const headers = response._headers; + delete response._status; + delete response._headers; + return { success: response, _status: status, _headers: headers }; + } + return { success: response }; + }, + error => { + return { + error: { code: error.code, error: error.message }, + }; + } + ); + }); + return Promise.all(promises) + .then(result => { + if (data.transaction === true) { + if (result.find(resultItem => typeof resultItem.error === 'object')) { + return config.database.abortTransactionalSession().then(() => { + return Promise.reject(result); + }); + } else { + return config.database.commitTransactionalSession().then(() => { + return result; + }); + } + } else { return result; - }); - } - } else { - return result; - } - }) - .catch(error => { - if ( - error && - error.find( - errorItem => typeof errorItem.error === 'object' && errorItem.error.code === 251 - ) && - transactionRetries > 0 - ) { - return batch(transactionRetries - 1); - } - throw error; + } + }) + .catch(error => { + if ( + error && + error.find( + errorItem => + typeof errorItem.error === 'object' && errorItem.error.code === 251 + ) && + transactionRetries > 0 + ) { + return batch(transactionRetries - 1); + } + throw error; + }); }); - }); - }; - return batch(5); - } + }; + return batch(5); + } - let query; - if (method === 'GET') { - query = data; - } + let query; + if (method === 'GET') { + query = data; + } - return new Promise((resolve, reject) => { - let requestContext; - try { - requestContext = structuredClone(options.context || {}); - } catch (error) { - reject( - new Parse.Error( - Parse.Error.INVALID_VALUE, - `Context contains non-cloneable values: ${error.message}` - ) - ); - return; - } - getAuth(options, config).then(auth => { - const request = { - body: data, - config, - auth, - info: { - applicationId: applicationId, - sessionToken: options.sessionToken, - installationId: options.installationId, - context: requestContext, - }, - query, - }; - return Promise.resolve() - .then(() => { - return router.tryRouteRequest(method, path, request); - }) - .then( - resp => { - const { response, status, headers = {} } = resp; - if (options.returnStatus) { - resolve({ ...response, _status: status, _headers: headers }); - } else { - resolve(response); - } - }, - err => { - if ( - err instanceof Parse.Error && - err.code == Parse.Error.INVALID_JSON && - err.message == `cannot route ${method} ${path}` - ) { - RESTController.request.apply(null, args).then(resolve, reject); - } else { - reject(err); - } - } - ); - }, reject); - }); + return new Promise((resolve, reject) => { + let requestContext; + try { + requestContext = structuredClone(options.context || {}); + } catch (error) { + reject( + new Parse.Error( + Parse.Error.INVALID_VALUE, + `Context contains non-cloneable values: ${error.message}` + ) + ); + return; + } + getAuth(options, config).then(auth => { + const request = { + body: data, + config, + auth, + info: { + applicationId: applicationId, + sessionToken: options.sessionToken, + installationId: options.installationId, + context: requestContext, + }, + query, + }; + return Promise.resolve() + .then(() => { + return router.tryRouteRequest(method, path, request); + }) + .then( + resp => { + const { response, status, headers = {} } = resp; + if (options.returnStatus) { + resolve({ ...response, _status: status, _headers: headers }); + } else { + resolve(response); + } + }, + err => { + if ( + err instanceof Parse.Error && + err.code == Parse.Error.INVALID_JSON && + err.message == `cannot route ${method} ${path}` + ) { + RESTController.request.apply(null, args).then(resolve, reject); + } else { + reject(err); + } + } + ); + }, reject); + }); + }); } return { From 4721ad70f759386969d7d7704aac09568173a174 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Thu, 23 Jul 2026 15:16:06 +0200 Subject: [PATCH 2/7] refactor: Rewrite ParseServerRESTController handleRequest with async/await --- src/ParseServerRESTController.js | 257 ++++++++++++++----------------- 1 file changed, 118 insertions(+), 139 deletions(-) diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index 23cd167e96..66ce629844 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -63,155 +63,134 @@ function applyRequestContextMiddleware(config) { } function ParseServerRESTController(applicationId, router) { - function handleRequest(method, path, data = {}, options = {}, config) { + async function handleRequest(method, path, data = {}, options = {}, config) { // Store the arguments, for later use if internal fails const args = arguments; const configWasProvided = !!config; - return Promise.resolve() - .then(() => { - if (!configWasProvided) { - config = Config.get(applicationId); - // Fresh config from AppCache has no Express middleware mutations; - // re-apply requestContextMiddleware for DI parity with HTTP. - return applyRequestContextMiddleware(config); - } - }) - .then(() => { - const serverURL = new URL(config.serverURL); - if (path.indexOf(serverURL.pathname) === 0) { - path = path.slice(serverURL.pathname.length, path.length); - } + if (!configWasProvided) { + config = Config.get(applicationId); + // Fresh config from AppCache has no Express middleware mutations; + // re-apply requestContextMiddleware for DI parity with HTTP. + await applyRequestContextMiddleware(config); + } - if (path[0] !== '/') { - path = '/' + path; - } + const serverURL = new URL(config.serverURL); + if (path.indexOf(serverURL.pathname) === 0) { + path = path.slice(serverURL.pathname.length, path.length); + } - if (path === '/batch') { - const batch = transactionRetries => { - let initialPromise = Promise.resolve(); - if (data.transaction === true) { - initialPromise = config.database.createTransactionalSession(); - } - return initialPromise.then(() => { - const promises = data.requests.map(request => { - return handleRequest( - request.method, - request.path, - request.body, - options, - config - ).then( - response => { - if (options.returnStatus) { - const status = response._status; - const headers = response._headers; - delete response._status; - delete response._headers; - return { success: response, _status: status, _headers: headers }; - } - return { success: response }; - }, - error => { - return { - error: { code: error.code, error: error.message }, - }; - } - ); - }); - return Promise.all(promises) - .then(result => { - if (data.transaction === true) { - if (result.find(resultItem => typeof resultItem.error === 'object')) { - return config.database.abortTransactionalSession().then(() => { - return Promise.reject(result); - }); - } else { - return config.database.commitTransactionalSession().then(() => { - return result; - }); - } - } else { - return result; - } - }) - .catch(error => { - if ( - error && - error.find( - errorItem => - typeof errorItem.error === 'object' && errorItem.error.code === 251 - ) && - transactionRetries > 0 - ) { - return batch(transactionRetries - 1); - } - throw error; - }); - }); - }; - return batch(5); - } + if (path[0] !== '/') { + path = '/' + path; + } - let query; - if (method === 'GET') { - query = data; + if (path === '/batch') { + const batch = async transactionRetries => { + if (data.transaction === true) { + await config.database.createTransactionalSession(); } - - return new Promise((resolve, reject) => { - let requestContext; - try { - requestContext = structuredClone(options.context || {}); - } catch (error) { - reject( - new Parse.Error( - Parse.Error.INVALID_VALUE, - `Context contains non-cloneable values: ${error.message}` - ) + const result = await Promise.all( + data.requests.map(request => { + return handleRequest( + request.method, + request.path, + request.body, + options, + config + ).then( + response => { + if (options.returnStatus) { + const status = response._status; + const headers = response._headers; + delete response._status; + delete response._headers; + return { success: response, _status: status, _headers: headers }; + } + return { success: response }; + }, + error => { + return { + error: { code: error.code, error: error.message }, + }; + } ); - return; + }) + ); + try { + if (data.transaction === true) { + if (result.find(resultItem => typeof resultItem.error === 'object')) { + await config.database.abortTransactionalSession(); + throw result; + } + await config.database.commitTransactionalSession(); } - getAuth(options, config).then(auth => { - const request = { - body: data, - config, - auth, - info: { - applicationId: applicationId, - sessionToken: options.sessionToken, - installationId: options.installationId, - context: requestContext, - }, - query, - }; - return Promise.resolve() - .then(() => { - return router.tryRouteRequest(method, path, request); - }) - .then( - resp => { - const { response, status, headers = {} } = resp; - if (options.returnStatus) { - resolve({ ...response, _status: status, _headers: headers }); - } else { - resolve(response); - } - }, - err => { - if ( - err instanceof Parse.Error && - err.code == Parse.Error.INVALID_JSON && - err.message == `cannot route ${method} ${path}` - ) { - RESTController.request.apply(null, args).then(resolve, reject); - } else { - reject(err); - } - } - ); - }, reject); - }); - }); + return result; + } catch (error) { + if ( + error && + error.find && + error.find( + errorItem => typeof errorItem.error === 'object' && errorItem.error.code === 251 + ) && + transactionRetries > 0 + ) { + return batch(transactionRetries - 1); + } + throw error; + } + }; + return batch(5); + } + + let query; + if (method === 'GET') { + query = data; + } + + let requestContext; + try { + requestContext = structuredClone(options.context || {}); + } catch (error) { + throw new Parse.Error( + Parse.Error.INVALID_VALUE, + `Context contains non-cloneable values: ${error.message}` + ); + } + + const auth = await getAuth(options, config); + const request = { + body: data, + config, + auth, + info: { + applicationId: applicationId, + sessionToken: options.sessionToken, + installationId: options.installationId, + context: requestContext, + }, + query, + }; + + try { + const { response, status, headers = {} } = await router.tryRouteRequest( + method, + path, + request + ); + if (options.returnStatus) { + return { ...response, _status: status, _headers: headers }; + } + return response; + } catch (err) { + if ( + err instanceof Parse.Error && + err.code == Parse.Error.INVALID_JSON && + err.message == `cannot route ${method} ${path}` + ) { + return RESTController.request.apply(null, args); + } + throw err; + } } return { From e45b7f7e1978c73cd01bdef73c71eaf20f673bde Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Thu, 23 Jul 2026 15:17:59 +0200 Subject: [PATCH 3/7] docs: Keep requestContextMiddleware option help concise --- src/Options/Definitions.js | 3 +-- src/Options/docs.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index be5571fc7d..64c54561eb 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -558,8 +558,7 @@ module.exports.ParseServerOptions = { }, requestContextMiddleware: { env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE', - help: - 'Options to customize the request context using inversion of control/dependency injection. Applied on Express HTTP requests and on internal directAccess requests via ParseServerRESTController (synthetic request; headers may be empty).', + help: 'Options to customize the request context using inversion of control/dependency injection.', }, requestKeywordDenylist: { env: 'PARSE_SERVER_REQUEST_KEYWORD_DENYLIST', diff --git a/src/Options/docs.js b/src/Options/docs.js index 58e991d0a4..564a9b1718 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -102,7 +102,7 @@ * @property {String} readOnlyMasterKey The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.

It is intended strictly for internal, server-side use — for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used. * @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only 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 read-only 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 read-only 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 `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`. * @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. - * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. Applied on Express HTTP requests and on internal `directAccess` requests via `ParseServerRESTController` (synthetic request; headers may be empty). + * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. * @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. From 624f49fc56a7393db68117ef9c7b84e1fe98faf3 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Thu, 23 Jul 2026 15:48:15 +0200 Subject: [PATCH 4/7] docs: Document synthetic req contract for requestContextMiddleware Clarify that directAccess uses a minimal synthetic request and that Express-only accessors are unavailable, addressing review feedback. --- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- src/ParseServerRESTController.js | 10 ++++++++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 64c54561eb..1927d6d1da 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -558,7 +558,7 @@ module.exports.ParseServerOptions = { }, requestContextMiddleware: { env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE', - help: 'Options to customize the request context using inversion of control/dependency injection.', + help: 'Options to customize the request context using inversion of control/dependency injection. Also applied on internal `directAccess` requests via a synthetic `req` that exposes only `config` and empty `headers`. Express-only accessors such as `req.get()`, `req.header()`, `req.ip`, and `req.body` are unavailable on that path.', }, requestKeywordDenylist: { env: 'PARSE_SERVER_REQUEST_KEYWORD_DENYLIST', diff --git a/src/Options/docs.js b/src/Options/docs.js index 564a9b1718..10eaa4601f 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -102,7 +102,7 @@ * @property {String} readOnlyMasterKey The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.

It is intended strictly for internal, server-side use — for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used. * @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only 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 read-only 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 read-only 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 `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`. * @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. - * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. + * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. Also applied on internal `directAccess` requests via a synthetic `req` that exposes only `config` and empty `headers`. Express-only accessors such as `req.get()`, `req.header()`, `req.ip`, and `req.body` are unavailable on that path. * @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. diff --git a/src/Options/index.js b/src/Options/index.js index 42f0ffb3b8..626bb2f242 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -418,7 +418,7 @@ export interface ParseServerOptions { /* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.
- rate limits are matched against the REST API URL path (`requestPath`) and therefore apply to REST API routes only; they do not apply to GraphQL operations, which are all served under the single GraphQL endpoint path (`graphQLPath`, default `/graphql`) and are identified by the request payload rather than the URL. To rate limit GraphQL, either set a `requestPath` for the GraphQL endpoint path to throttle the entire GraphQL API, or use a GraphQL-aware rate limiting solution (for example a schema-directive-based rate limiter) for per-operation limits. :DEFAULT: [] */ rateLimit: ?(RateLimitOptions[]); - /* Options to customize the request context using inversion of control/dependency injection.*/ + /* Options to customize the request context using inversion of control/dependency injection. Also applied on internal `directAccess` requests via a synthetic `req` that exposes only `config` and empty `headers`. Express-only accessors such as `req.get()`, `req.header()`, `req.ip`, and `req.body` are unavailable on that path.*/ requestContextMiddleware: ?(req: any, res: any, next: any) => void; /* If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`. :DEFAULT: true */ diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index 66ce629844..df57e77a54 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -32,6 +32,15 @@ function getAuth(options = {}, config) { /** * Apply requestContextMiddleware on a synthetic request so directAccess ops * get the same per-request DI as Express HTTP requests. + * + * Synthetic request contract (minimal supported fields only): + * - `req.config` — Parse Server config (DI target) + * - `req.headers` — empty object `{}` (no HTTP headers on this path) + * + * Express-only request properties and methods such as `req.get()`, + * `req.header()`, `req.ip`, and `req.body` are unavailable. Middleware must + * use only the supported fields above; this path intentionally does not add + * partial Express compatibility. */ function applyRequestContextMiddleware(config) { if (typeof config.requestContextMiddleware !== 'function') { @@ -50,6 +59,7 @@ function applyRequestContextMiddleware(config) { resolve(); } }; + // Minimal synthetic req — see contract in JSDoc above. const req = { config, headers: {} }; try { const maybePromise = config.requestContextMiddleware(req, {}, done); From fc882d6b3385d03e4ed06776784f40bec395c72b Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Thu, 23 Jul 2026 15:52:53 +0200 Subject: [PATCH 5/7] refactor: Convert applyRequestContextMiddleware to async/await Align with handleRequest style while keeping callback and Promise middleware settlement support. --- src/ParseServerRESTController.js | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index df57e77a54..2207e04bfb 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -42,11 +42,17 @@ function getAuth(options = {}, config) { * use only the supported fields above; this path intentionally does not add * partial Express compatibility. */ -function applyRequestContextMiddleware(config) { +async function applyRequestContextMiddleware(config) { if (typeof config.requestContextMiddleware !== 'function') { - return Promise.resolve(); + return; } - return new Promise((resolve, reject) => { + + // Minimal synthetic req — see contract in JSDoc above. + const req = { config, headers: {} }; + + // Bridge callback-style Express middleware (`next(err)`) and Promise-returning + // middleware into a single awaitable settlement. + await new Promise((resolve, reject) => { let settled = false; const done = err => { if (settled) { @@ -59,15 +65,17 @@ function applyRequestContextMiddleware(config) { resolve(); } }; - // Minimal synthetic req — see contract in JSDoc above. - const req = { config, headers: {} }; + + let result; try { - const maybePromise = config.requestContextMiddleware(req, {}, done); - if (maybePromise && typeof maybePromise.then === 'function') { - maybePromise.then(() => done(), done); - } + result = config.requestContextMiddleware(req, {}, done); } catch (err) { done(err); + return; + } + + if (result != null && typeof result.then === 'function') { + result.then(() => done(), done); } }); } From 343023ad9e8d98e92c925ce2fbae9ece2fb84861 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Thu, 23 Jul 2026 16:09:47 +0200 Subject: [PATCH 6/7] fix: Allow Promise return type for requestContextMiddleware Align the Flow type with runtime support for async DI middleware. --- src/Options/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Options/index.js b/src/Options/index.js index 626bb2f242..9c27fd45ef 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -419,7 +419,7 @@ export interface ParseServerOptions { :DEFAULT: [] */ rateLimit: ?(RateLimitOptions[]); /* Options to customize the request context using inversion of control/dependency injection. Also applied on internal `directAccess` requests via a synthetic `req` that exposes only `config` and empty `headers`. Express-only accessors such as `req.get()`, `req.header()`, `req.ip`, and `req.body` are unavailable on that path.*/ - requestContextMiddleware: ?(req: any, res: any, next: any) => void; + requestContextMiddleware: ?(req: any, res: any, next: any) => void | Promise; /* If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`. :DEFAULT: true */ enableSanitizedErrorResponse: ?boolean; From da446a8f7feb989a71239ab36b246a4d7da3fc23 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Thu, 23 Jul 2026 16:56:39 +0200 Subject: [PATCH 7/7] docs: Require next()/Promise settlement for requestContextMiddleware Document that directAccess synthetic res is bare and middleware must settle via next() or a Promise to avoid hanging nested ops. --- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- src/ParseServerRESTController.js | 10 ++++++++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 1927d6d1da..155f40257e 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -558,7 +558,7 @@ module.exports.ParseServerOptions = { }, requestContextMiddleware: { env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE', - help: 'Options to customize the request context using inversion of control/dependency injection. Also applied on internal `directAccess` requests via a synthetic `req` that exposes only `config` and empty `headers`. Express-only accessors such as `req.get()`, `req.header()`, `req.ip`, and `req.body` are unavailable on that path.', + help: 'Options to customize the request context using inversion of control/dependency injection. Also applied on internal `directAccess` requests via a synthetic `req` that exposes only `config` and empty `headers`, with a bare `res` object. On that path the middleware must settle via `next()` or a Promise; do not rely on response termination. Express-only accessors such as `req.get()`, `req.header()`, `req.ip`, and `req.body` are unavailable.', }, requestKeywordDenylist: { env: 'PARSE_SERVER_REQUEST_KEYWORD_DENYLIST', diff --git a/src/Options/docs.js b/src/Options/docs.js index 10eaa4601f..67eb7ded53 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -102,7 +102,7 @@ * @property {String} readOnlyMasterKey The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.

It is intended strictly for internal, server-side use — for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used. * @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only 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 read-only 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 read-only 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 `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`. * @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. - * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. Also applied on internal `directAccess` requests via a synthetic `req` that exposes only `config` and empty `headers`. Express-only accessors such as `req.get()`, `req.header()`, `req.ip`, and `req.body` are unavailable on that path. + * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. Also applied on internal `directAccess` requests via a synthetic `req` that exposes only `config` and empty `headers`, with a bare `res` object. On that path the middleware must settle via `next()` or a Promise; do not rely on response termination. Express-only accessors such as `req.get()`, `req.header()`, `req.ip`, and `req.body` are unavailable. * @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. diff --git a/src/Options/index.js b/src/Options/index.js index 9c27fd45ef..effb2f4f32 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -418,7 +418,7 @@ export interface ParseServerOptions { /* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.
- rate limits are matched against the REST API URL path (`requestPath`) and therefore apply to REST API routes only; they do not apply to GraphQL operations, which are all served under the single GraphQL endpoint path (`graphQLPath`, default `/graphql`) and are identified by the request payload rather than the URL. To rate limit GraphQL, either set a `requestPath` for the GraphQL endpoint path to throttle the entire GraphQL API, or use a GraphQL-aware rate limiting solution (for example a schema-directive-based rate limiter) for per-operation limits. :DEFAULT: [] */ rateLimit: ?(RateLimitOptions[]); - /* Options to customize the request context using inversion of control/dependency injection. Also applied on internal `directAccess` requests via a synthetic `req` that exposes only `config` and empty `headers`. Express-only accessors such as `req.get()`, `req.header()`, `req.ip`, and `req.body` are unavailable on that path.*/ + /* Options to customize the request context using inversion of control/dependency injection. Also applied on internal `directAccess` requests via a synthetic `req` that exposes only `config` and empty `headers`, with a bare `res` object. On that path the middleware must settle via `next()` or a Promise; do not rely on response termination. Express-only accessors such as `req.get()`, `req.header()`, `req.ip`, and `req.body` are unavailable.*/ requestContextMiddleware: ?(req: any, res: any, next: any) => void | Promise; /* If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`. :DEFAULT: true */ diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index 2207e04bfb..e6d1811975 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -34,13 +34,19 @@ function getAuth(options = {}, config) { * get the same per-request DI as Express HTTP requests. * * Synthetic request contract (minimal supported fields only): - * - `req.config` — Parse Server config (DI target) - * - `req.headers` — empty object `{}` (no HTTP headers on this path) + * - `req.config` — Parse Server config (DI target only) + * - `req.headers` — empty object `{}` (no HTTP headers on this path; DI support only) + * - `res` — bare object `{}` (not a real Express response) * * Express-only request properties and methods such as `req.get()`, * `req.header()`, `req.ip`, and `req.body` are unavailable. Middleware must * use only the supported fields above; this path intentionally does not add * partial Express compatibility. + * + * Settlement is required: the middleware must call `next()` / `next(err)`, or + * return a Promise that resolves or rejects. Do not rely on response + * termination (`res.send`, `res.end`, `res.json`, etc.) or other implicit + * paths — those will hang nested directAccess ops indefinitely. */ async function applyRequestContextMiddleware(config) { if (typeof config.requestContextMiddleware !== 'function') {