From 04d96198ace8112220e893505dfd8ca76189a05e Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 19 Jun 2026 04:17:19 +0200
Subject: [PATCH] feat: Add option to disallow aggregation pipelines for the
read-only master key
---
spec/ParseQuery.Aggregate.spec.js | 63 +++++++++++++++++++++++++++++++
src/Deprecator/Deprecations.js | 5 +++
src/Options/Definitions.js | 8 +++-
src/Options/docs.js | 3 +-
src/Options/index.js | 6 ++-
src/Routers/AggregateRouter.js | 6 +++
6 files changed, 88 insertions(+), 3 deletions(-)
diff --git a/spec/ParseQuery.Aggregate.spec.js b/spec/ParseQuery.Aggregate.spec.js
index ac615b1fde..3d7d4d5307 100644
--- a/spec/ParseQuery.Aggregate.spec.js
+++ b/spec/ParseQuery.Aggregate.spec.js
@@ -1774,3 +1774,66 @@ describe('Parse.Query Aggregate testing', () => {
expect(results[0].total).toBe(1);
});
});
+
+describe('Parse.Query Aggregate readOnlyMasterKey', () => {
+ const readOnlyMasterKeyOptions = {
+ headers: {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Rest-API-Key': 'test',
+ 'X-Parse-Master-Key': 'read-only-test',
+ 'Content-Type': 'application/json',
+ },
+ json: true,
+ };
+
+ it('allows the read-only master key to run aggregation pipelines by default', async () => {
+ await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
+ const options = Object.assign({}, readOnlyMasterKeyOptions, {
+ body: { $group: { _id: '$name' } },
+ });
+ const resp = await get(Parse.serverURL + '/aggregate/TestObject', options);
+ expect(resp.results.length).toBe(1);
+ expect(resp.results[0].objectId).toBe('foo');
+ });
+
+ it('blocks the read-only master key from running aggregation pipelines when allowAggregationForReadOnlyMasterKey is false', async () => {
+ await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false });
+ await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
+ const options = Object.assign({}, readOnlyMasterKeyOptions, {
+ body: { $group: { _id: '$name' } },
+ });
+ try {
+ await get(Parse.serverURL + '/aggregate/TestObject', options);
+ fail('aggregation should be forbidden for the read-only master key');
+ } catch (e) {
+ expect(e.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
+ }
+ });
+
+ it('blocks a write-capable $out stage for the read-only master key when allowAggregationForReadOnlyMasterKey is false', async () => {
+ await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false });
+ await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
+ const options = Object.assign({}, readOnlyMasterKeyOptions, {
+ body: {
+ pipeline: [{ $match: { name: 'foo' } }, { $out: 'CreatedByReadOnlyAggregate' }],
+ },
+ });
+ try {
+ await get(Parse.serverURL + '/aggregate/TestObject', options);
+ fail('aggregation should be forbidden for the read-only master key');
+ } catch (e) {
+ expect(e.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
+ }
+ });
+
+ it('still allows the full master key to run aggregation pipelines when allowAggregationForReadOnlyMasterKey is false', async () => {
+ await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false });
+ await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
+ const options = Object.assign({}, masterKeyOptions, {
+ body: { $group: { _id: '$name' } },
+ });
+ const resp = await get(Parse.serverURL + '/aggregate/TestObject', options);
+ expect(resp.results.length).toBe(1);
+ expect(resp.results[0].objectId).toBe('foo');
+ });
+});
diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js
index 5eb78c88c0..4d8ffb0195 100644
--- a/src/Deprecator/Deprecations.js
+++ b/src/Deprecator/Deprecations.js
@@ -113,4 +113,9 @@ module.exports = [
changeNewDefault: 'true',
solution: "Set 'installation.duplicateDeviceTokenActionEnforceAuth' to 'true' to enforce the caller's auth context (and the resulting ACL and CLP) when Parse Server deduplicates _Installation records sharing the same deviceToken. Set to 'false' to keep the current behavior of bypassing permissions on the dedup operation.",
},
+ {
+ optionKey: 'allowAggregationForReadOnlyMasterKey',
+ changeNewDefault: 'false',
+ solution: "Set 'allowAggregationForReadOnlyMasterKey' to 'false' to prevent the read-only master key from running aggregation pipelines, which can include write-capable stages (e.g. '$out', '$merge'). Set to 'true' to keep the current behavior where the read-only master key can run aggregation pipelines.",
+ },
];
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 20882e6896..5bd564088e 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -58,6 +58,12 @@ module.exports.ParseServerOptions = {
action: parsers.objectParser,
type: 'AccountLockoutOptions',
},
+ allowAggregationForReadOnlyMasterKey: {
+ env: 'PARSE_SERVER_ALLOW_AGGREGATION_FOR_READ_ONLY_MASTER_KEY',
+ help: 'Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`.',
+ action: parsers.booleanParser,
+ default: true,
+ },
allowClientClassCreation: {
env: 'PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION',
help: 'Enable (or disable) client class creation, defaults to false',
@@ -535,7 +541,7 @@ module.exports.ParseServerOptions = {
},
readOnlyMasterKey: {
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY',
- help: 'Read-only key, which has the same capabilities as MasterKey without writes',
+ help: '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 \u2014 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.',
},
readOnlyMasterKeyIps: {
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY_IPS',
diff --git a/src/Options/docs.js b/src/Options/docs.js
index b1469ab191..fcc2b9500b 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -13,6 +13,7 @@
/**
* @interface ParseServerOptions
* @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.
Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL.
+ * @property {Boolean} allowAggregationForReadOnlyMasterKey Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`.
* @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to false
* @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId
* @property {Boolean} allowExpiredAuthDataToken Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`.
@@ -98,7 +99,7 @@
* @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications
* @property {QueryServerOptions} query Query-related server defaults.
* @property {RateLimitOptions[]} rateLimit 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.
- * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes
+ * @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.
diff --git a/src/Options/index.js b/src/Options/index.js
index c20be12021..baa198e843 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -158,8 +158,12 @@ export interface ParseServerOptions {
/* Key for REST calls
:ENV: PARSE_SERVER_REST_API_KEY */
restAPIKey: ?string;
- /* Read-only key, which has the same capabilities as MasterKey without writes */
+ /* 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. */
readOnlyMasterKey: ?string;
+ /* Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`.
+ :ENV: PARSE_SERVER_ALLOW_AGGREGATION_FOR_READ_ONLY_MASTER_KEY
+ :DEFAULT: true */
+ allowAggregationForReadOnlyMasterKey: ?boolean;
/* Key sent with outgoing webhook calls */
webhookKey: ?string;
/* Key for your files */
diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js
index d36a76e79d..a367ba2488 100644
--- a/src/Routers/AggregateRouter.js
+++ b/src/Routers/AggregateRouter.js
@@ -6,6 +6,12 @@ import UsersRouter from './UsersRouter';
export class AggregateRouter extends ClassesRouter {
async handleFind(req) {
+ if (req.auth && req.auth.isReadOnly && req.config && !req.config.allowAggregationForReadOnlyMasterKey) {
+ throw new Parse.Error(
+ Parse.Error.OPERATION_FORBIDDEN,
+ 'Cannot run an aggregation pipeline when using the readOnlyMasterKey'
+ );
+ }
const body = Object.assign(req.body || {}, ClassesRouter.JSONFromQuery(req.query));
const options = {};
if (body.distinct) {