diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md
index eb7f463638..6ac20b4616 100644
--- a/DEPRECATIONS.md
+++ b/DEPRECATIONS.md
@@ -2,19 +2,20 @@
The following is a list of deprecations, according to the [Deprecation Policy](https://github.com/parse-community/parse-server/blob/master/CONTRIBUTING.md#deprecation-policy). After a feature becomes deprecated, and giving developers time to adapt to the change, the deprecated feature will eventually be removed, leading to a breaking change. Developer feedback during the deprecation period may postpone or even revoke the introduction of the breaking change.
-| ID | Change | Issue | Deprecation [ℹ️][i_deprecation] | Planned Removal [ℹ️][i_removal] | Status [ℹ️][i_status] | Notes |
-|--------|-------------------------------------------------|----------------------------------------------------------------------|---------------------------------|---------------------------------|-----------------------|-------|
-| DEPPS1 | Native MongoDB syntax in aggregation pipeline | [#7338](https://github.com/parse-community/parse-server/issues/7338) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
-| DEPPS2 | Config option `directAccess` defaults to `true` | [#6636](https://github.com/parse-community/parse-server/pull/6636) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
-| DEPPS3 | Config option `enforcePrivateUsers` defaults to `true` | [#7319](https://github.com/parse-community/parse-server/pull/7319) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
-| DEPPS4 | Remove convenience method for http request `Parse.Cloud.httpRequest` | [#7589](https://github.com/parse-community/parse-server/pull/7589) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
-| DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
-| DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
-| DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
-| DEPPS8 | Login with expired 3rd party authentication token defaults to `false` | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
-| DEPPS9 | Rename LiveQuery `fields` option to `keys` | [#8389](https://github.com/parse-community/parse-server/issues/8389) | 6.0.0 (2023) | 7.0.0 (2024) | removed | - |
-| DEPPS10 | Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 9.0.0 (2026) | deprecated | - |
-| DEPPS11 | Replace `PublicAPIRouter` with `PagesRouter` | [#7625](https://github.com/parse-community/parse-server/issues/7625) | 8.0.0 (2025) | 9.0.0 (2026) | deprecated | - |
+| ID | Change | Issue | Deprecation [ℹ️][i_deprecation] | Planned Removal [ℹ️][i_removal] | Status [ℹ️][i_status] | Notes |
+|---------|----------------------------------------------------------------------------------------------|----------------------------------------------------------------------|---------------------------------|---------------------------------|-----------------------|-------|
+| DEPPS1 | Native MongoDB syntax in aggregation pipeline | [#7338](https://github.com/parse-community/parse-server/issues/7338) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
+| DEPPS2 | Config option `directAccess` defaults to `true` | [#6636](https://github.com/parse-community/parse-server/pull/6636) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
+| DEPPS3 | Config option `enforcePrivateUsers` defaults to `true` | [#7319](https://github.com/parse-community/parse-server/pull/7319) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
+| DEPPS4 | Remove convenience method for http request `Parse.Cloud.httpRequest` | [#7589](https://github.com/parse-community/parse-server/pull/7589) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
+| DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
+| DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
+| DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
+| DEPPS8 | Login with expired 3rd party authentication token defaults to `false` | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
+| DEPPS9 | Rename LiveQuery `fields` option to `keys` | [#8389](https://github.com/parse-community/parse-server/issues/8389) | 6.0.0 (2023) | 7.0.0 (2024) | removed | - |
+| DEPPS10 | Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 9.0.0 (2026) | deprecated | - |
+| DEPPS11 | Replace `PublicAPIRouter` with `PagesRouter` | [#7625](https://github.com/parse-community/parse-server/issues/7625) | 8.0.0 (2025) | 9.0.0 (2026) | deprecated | - |
+| DEPPS12 | Database option `allowPublicExplain` will default to `true` | [#7519](https://github.com/parse-community/parse-server/issues/7519) | 8.5.0 (2025) | 9.0.0 (2026) | deprecated | - |
[i_deprecation]: ## "The version and date of the deprecation."
[i_removal]: ## "The version and date of the planned removal."
diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js
index 98ef70564f..2663e649ca 100644
--- a/spec/ParseQuery.spec.js
+++ b/spec/ParseQuery.spec.js
@@ -8,6 +8,7 @@ const Parse = require('parse/node');
const request = require('../lib/request');
const ParseServerRESTController = require('../lib/ParseServerRESTController').ParseServerRESTController;
const ParseServer = require('../lib/ParseServer').default;
+const Deprecator = require('../lib/Deprecator/Deprecator').default;
const masterKeyHeaders = {
'X-Parse-Application-Id': 'test',
@@ -5384,4 +5385,102 @@ describe('Parse.Query testing', () => {
expect(query1.length).toEqual(1);
});
});
+
+ describe('allowPublicExplain', () => {
+ it_id('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d')(it_only_db('mongo'))(
+ 'explain works with and without master key when allowPublicExplain is true',
+ async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI: 'mongodb://localhost:27017/parse',
+ databaseOptions: {
+ allowPublicExplain: true,
+ },
+ });
+
+ const obj = new TestObject({ foo: 'bar' });
+ await obj.save();
+
+ // Without master key
+ const query = new Parse.Query(TestObject);
+ query.explain();
+ const resultWithoutMasterKey = await query.find();
+ expect(resultWithoutMasterKey).toBeDefined();
+
+ // With master key
+ const queryWithMasterKey = new Parse.Query(TestObject);
+ queryWithMasterKey.explain();
+ const resultWithMasterKey = await queryWithMasterKey.find({ useMasterKey: true });
+ expect(resultWithMasterKey).toBeDefined();
+ }
+ );
+
+ it_id('b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e')(it_only_db('mongo'))(
+ 'explain requires master key when allowPublicExplain is false',
+ async () => {
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI: 'mongodb://localhost:27017/parse',
+ databaseOptions: {
+ allowPublicExplain: false,
+ },
+ });
+
+ const obj = new TestObject({ foo: 'bar' });
+ await obj.save();
+
+ // Without master key
+ const query = new Parse.Query(TestObject);
+ query.explain();
+ await expectAsync(query.find()).toBeRejectedWith(
+ new Parse.Error(
+ Parse.Error.INVALID_QUERY,
+ 'Using the explain query parameter requires the master key'
+ )
+ );
+
+ // With master key
+ const queryWithMasterKey = new Parse.Query(TestObject);
+ queryWithMasterKey.explain();
+ const result = await queryWithMasterKey.find({ useMasterKey: true });
+ expect(result).toBeDefined();
+ }
+ );
+
+ it_id('c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f')(it_only_db('mongo'))(
+ 'explain works with and without master key by default',
+ async () => {
+ const logger = require('../lib/logger').logger;
+ const logSpy = spyOn(logger, 'warn').and.callFake(() => {});
+
+ await reconfigureServer({
+ databaseAdapter: undefined,
+ databaseURI: 'mongodb://localhost:27017/parse',
+ databaseOptions: {
+ allowPublicExplain: undefined,
+ },
+ });
+
+ // Verify deprecation warning is logged when allowPublicExplain is not explicitly set
+ expect(logSpy).toHaveBeenCalledWith(
+ jasmine.stringMatching(/DeprecationWarning.*databaseOptions\.allowPublicExplain.*false/)
+ );
+
+ const obj = new TestObject({ foo: 'bar' });
+ await obj.save();
+
+ // Without master key
+ const query = new Parse.Query(TestObject);
+ query.explain();
+ const resultWithoutMasterKey = await query.find();
+ expect(resultWithoutMasterKey).toBeDefined();
+
+ // With master key
+ const queryWithMasterKey = new Parse.Query(TestObject);
+ queryWithMasterKey.explain();
+ const resultWithMasterKey = await queryWithMasterKey.find({ useMasterKey: true });
+ expect(resultWithMasterKey).toBeDefined();
+ }
+ );
+ });
});
diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js
index 3e5f312dd7..aea4468da8 100644
--- a/spec/SecurityCheckGroups.spec.js
+++ b/spec/SecurityCheckGroups.spec.js
@@ -60,6 +60,26 @@ describe('Security Check Groups', () => {
expect(group.checks()[4].checkState()).toBe(CheckState.fail);
expect(group.checks()[5].checkState()).toBe(CheckState.fail);
});
+
+ it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => {
+ config.databaseAdapter = undefined;
+ config.databaseOptions = { allowPublicExplain: false };
+ await reconfigureServer(config);
+
+ const group = new CheckGroupServerConfig();
+ await group.run();
+ expect(group.checks()[6].checkState()).toBe(CheckState.success);
+ });
+
+ it_only_db('mongo')('checks fail correctly (MongoDB specific)', async () => {
+ config.databaseAdapter = undefined;
+ config.databaseOptions = { allowPublicExplain: true };
+ await reconfigureServer(config);
+
+ const group = new CheckGroupServerConfig();
+ await group.run();
+ expect(group.checks()[6].checkState()).toBe(CheckState.fail);
+ });
});
describe('CheckGroupDatabase', () => {
diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js
index 57f7543085..afe9f39282 100644
--- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js
+++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js
@@ -148,7 +148,7 @@ export class MongoStorageAdapter implements StorageAdapter {
this._uri = uri;
this._collectionPrefix = collectionPrefix;
this._mongoOptions = { ...mongoOptions };
- this._onchange = () => { };
+ this._onchange = () => {};
// MaxTimeMS is not a global MongoDB client option, it is applied per operation.
this._maxTimeMS = mongoOptions.maxTimeMS;
@@ -157,10 +157,12 @@ export class MongoStorageAdapter implements StorageAdapter {
this.schemaCacheTtl = mongoOptions.schemaCacheTtl;
this.disableIndexFieldValidation = !!mongoOptions.disableIndexFieldValidation;
this._logClientEvents = mongoOptions.logClientEvents;
+
// Remove Parse Server-specific options that should not be passed to MongoDB client
// Note: We only delete from this._mongoOptions, not from the original mongoOptions object,
// because other components (like DatabaseController) need access to these options
for (const key of [
+ 'allowPublicExplain',
'enableSchemaHooks',
'schemaCacheTtl',
'maxTimeMS',
diff --git a/src/Config.js b/src/Config.js
index 42b24f2d89..241edf9771 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -659,6 +659,11 @@ export class Config {
} else if (typeof databaseOptions.schemaCacheTtl !== 'number') {
throw `databaseOptions.schemaCacheTtl must be a number`;
}
+ if (databaseOptions.allowPublicExplain === undefined) {
+ databaseOptions.allowPublicExplain = DatabaseOptions.allowPublicExplain.default;
+ } else if (typeof databaseOptions.allowPublicExplain !== 'boolean') {
+ throw `Parse Server option 'databaseOptions.allowPublicExplain' must be a boolean.`;
+ }
}
static validateRateLimit(rateLimit) {
diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js
index 970364432b..c63225f5b5 100644
--- a/src/Deprecator/Deprecations.js
+++ b/src/Deprecator/Deprecations.js
@@ -18,4 +18,5 @@
module.exports = [
{ optionKey: 'encodeParseObjectInCloudFunction', changeNewDefault: 'true' },
{ optionKey: 'enableInsecureAuthAdapters', changeNewDefault: 'false' },
+ { optionKey: 'databaseOptions.allowPublicExplain', changeNewDefault: 'false' },
];
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 6930020de7..1ae9512823 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -1137,6 +1137,13 @@ module.exports.LogClientEvent = {
},
};
module.exports.DatabaseOptions = {
+ allowPublicExplain: {
+ env: 'PARSE_SERVER_DATABASE_ALLOW_PUBLIC_EXPLAIN',
+ help:
+ 'Set to `true` to allow `Parse.Query.explain` without master key.
\u26A0\uFE0F Enabling this option may expose sensitive query performance data to unauthorized users and could potentially be exploited for malicious purposes.',
+ action: parsers.booleanParser,
+ default: true,
+ },
appName: {
env: 'PARSE_SERVER_DATABASE_APP_NAME',
help:
diff --git a/src/Options/docs.js b/src/Options/docs.js
index d3e1f258d9..cdbd06de45 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -257,6 +257,7 @@
/**
* @interface DatabaseOptions
+ * @property {Boolean} allowPublicExplain Set to `true` to allow `Parse.Query.explain` without master key.
⚠️ Enabling this option may expose sensitive query performance data to unauthorized users and could potentially be exploited for malicious purposes.
* @property {String} appName The MongoDB driver option to specify the name of the application that created this MongoClient instance.
* @property {String} authMechanism The MongoDB driver option to specify the authentication mechanism that MongoDB will use to authenticate the connection.
* @property {Any} authMechanismProperties The MongoDB driver option to specify properties for the specified authMechanism as a comma-separated list of colon-separated key-value pairs.
diff --git a/src/Options/index.js b/src/Options/index.js
index 090dba62e7..81dbc3c536 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -751,6 +751,9 @@ export interface DatabaseOptions {
createIndexRoleName: ?boolean;
/* Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later. */
disableIndexFieldValidation: ?boolean;
+ /* Set to `true` to allow `Parse.Query.explain` without master key.
⚠️ Enabling this option may expose sensitive query performance data to unauthorized users and could potentially be exploited for malicious purposes.
+ :DEFAULT: true */
+ allowPublicExplain: ?boolean;
/* An array of MongoDB client event configurations to enable logging of specific events. */
logClientEvents: ?(LogClientEvent[]);
}
diff --git a/src/Security/CheckGroups/CheckGroupServerConfig.js b/src/Security/CheckGroups/CheckGroupServerConfig.js
index 05a52a0275..ab2dfc4507 100644
--- a/src/Security/CheckGroups/CheckGroupServerConfig.js
+++ b/src/Security/CheckGroups/CheckGroupServerConfig.js
@@ -90,6 +90,21 @@ class CheckGroupServerConfig extends CheckGroup {
}
},
}),
+ new Check({
+ title: 'Public database explain disabled',
+ warning:
+ 'Database explain queries are publicly accessible, which may expose sensitive database performance information and schema details.',
+ solution:
+ "Change Parse Server configuration to 'databaseOptions.allowPublicExplain: false'. You will need to use master key to run explain queries.",
+ check: () => {
+ if (
+ config.databaseOptions?.allowPublicExplain === true ||
+ config.databaseOptions?.allowPublicExplain == null
+ ) {
+ throw 1;
+ }
+ },
+ }),
];
}
}
diff --git a/src/rest.js b/src/rest.js
index 8297121a68..e2e688a972 100644
--- a/src/rest.js
+++ b/src/rest.js
@@ -35,6 +35,17 @@ async function runFindTriggers(
) {
const { isGet } = options;
+ if (restOptions && restOptions.explain && !auth.isMaster) {
+ const allowPublicExplain = config.databaseOptions?.allowPublicExplain ?? true;
+
+ if (!allowPublicExplain) {
+ throw new Parse.Error(
+ Parse.Error.INVALID_QUERY,
+ 'Using the explain query parameter requires the master key'
+ );
+ }
+ }
+
// Run beforeFind trigger - may modify query or return objects directly
const result = await triggers.maybeRunQueryTrigger(
triggers.Types.beforeFind,
diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts
index 3cb604bbe1..3332d0693c 100644
--- a/types/Options/index.d.ts
+++ b/types/Options/index.d.ts
@@ -238,6 +238,7 @@ export interface DatabaseOptions {
authSource?: string;
autoSelectFamily?: boolean;
autoSelectFamilyAttemptTimeout?: number;
+ allowPublicExplain?: boolean;
compressors?: string[] | string;
connectTimeoutMS?: number;
directConnection?: boolean;