From af4a7c3e03962d1ccddf6bff51d03af80fb754b9 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 7 Mar 2026 22:11:23 +0000
Subject: [PATCH 1/2] fix
---
resources/buildConfigDefinitions.js | 2 +
spec/GraphQLQueryComplexity.spec.js | 181 ++++++++++
spec/ParseGraphQLServer.spec.js | 6 +
spec/RequestComplexity.spec.js | 327 ++++++++++++++++++
spec/RestQuery.spec.js | 1 +
spec/SecurityCheckGroups.spec.js | 9 +
src/Config.js | 28 ++
src/GraphQL/ParseGraphQLServer.js | 3 +-
src/GraphQL/helpers/queryComplexity.js | 99 ++++++
src/Options/Definitions.js | 44 +++
src/Options/docs.js | 10 +
src/Options/index.js | 24 ++
src/RestQuery.js | 58 +++-
.../CheckGroups/CheckGroupServerConfig.js | 17 +
14 files changed, 804 insertions(+), 5 deletions(-)
create mode 100644 spec/GraphQLQueryComplexity.spec.js
create mode 100644 spec/RequestComplexity.spec.js
create mode 100644 src/GraphQL/helpers/queryComplexity.js
diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js
index 0b7dcdac3d..350492f4f1 100644
--- a/resources/buildConfigDefinitions.js
+++ b/resources/buildConfigDefinitions.js
@@ -22,6 +22,7 @@ const nestedOptionTypes = [
'PagesOptions',
'PagesRoute',
'PasswordPolicyOptions',
+ 'RequestComplexityOptions',
'SecurityOptions',
'SchemaOptions',
'LogLevels',
@@ -45,6 +46,7 @@ const nestedOptionEnvPrefix = {
ParseServerOptions: 'PARSE_SERVER_',
PasswordPolicyOptions: 'PARSE_SERVER_PASSWORD_POLICY_',
RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_',
+ RequestComplexityOptions: 'PARSE_SERVER_REQUEST_COMPLEXITY_',
SchemaOptions: 'PARSE_SERVER_SCHEMA_',
SecurityOptions: 'PARSE_SERVER_SECURITY_',
};
diff --git a/spec/GraphQLQueryComplexity.spec.js b/spec/GraphQLQueryComplexity.spec.js
new file mode 100644
index 0000000000..976cc761f4
--- /dev/null
+++ b/spec/GraphQLQueryComplexity.spec.js
@@ -0,0 +1,181 @@
+'use strict';
+
+const http = require('http');
+const express = require('express');
+const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
+require('./helper');
+const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
+
+describe('graphql query complexity', () => {
+ let httpServer;
+ let graphQLServer;
+ const headers = {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Javascript-Key': 'test',
+ 'Content-Type': 'application/json',
+ };
+
+ async function setupGraphQL(serverOptions = {}) {
+ if (httpServer) {
+ await new Promise(resolve => httpServer.close(resolve));
+ }
+ const server = await reconfigureServer(serverOptions);
+ const expressApp = express();
+ httpServer = http.createServer(expressApp);
+ expressApp.use('/parse', server.app);
+ graphQLServer = new ParseGraphQLServer(server, {
+ graphQLPath: '/graphql',
+ });
+ graphQLServer.applyGraphQL(expressApp);
+ await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve));
+ }
+
+ async function graphqlRequest(query, requestHeaders = headers) {
+ const response = await fetch('http://localhost:13378/graphql', {
+ method: 'POST',
+ headers: requestHeaders,
+ body: JSON.stringify({ query }),
+ });
+ return response.json();
+ }
+
+ // Returns a query with depth 4: users(1) > edges(2) > node(3) > objectId(4)
+ function buildDeepQuery() {
+ return '{ users { edges { node { objectId } } } }';
+ }
+
+ function buildWideQuery(fieldCount) {
+ const fields = Array.from({ length: fieldCount }, (_, i) => `field${i}: objectId`).join('\n ');
+ return `{ users { edges { node { ${fields} } } } }`;
+ }
+
+ afterEach(async () => {
+ if (httpServer) {
+ await new Promise(resolve => httpServer.close(resolve));
+ httpServer = null;
+ }
+ });
+
+ describe('depth limit', () => {
+ it('should reject query exceeding depth limit', async () => {
+ await setupGraphQL({
+ requestComplexity: { graphQLDepth: 3 },
+ });
+ const result = await graphqlRequest(buildDeepQuery());
+ expect(result.errors).toBeDefined();
+ expect(result.errors[0].message).toMatch(
+ /GraphQL query depth of \d+ exceeds maximum allowed depth of 3/
+ );
+ });
+
+ it('should allow query within depth limit', async () => {
+ await setupGraphQL({
+ requestComplexity: { graphQLDepth: 10 },
+ });
+ const result = await graphqlRequest(buildDeepQuery());
+ expect(result.errors).toBeUndefined();
+ });
+
+ it('should allow deep query with master key', async () => {
+ await setupGraphQL({
+ requestComplexity: { graphQLDepth: 3 },
+ });
+ const result = await graphqlRequest(buildDeepQuery(), {
+ ...headers,
+ 'X-Parse-Master-Key': 'test',
+ });
+ expect(result.errors).toBeUndefined();
+ });
+
+ it('should allow unlimited depth when graphQLDepth is -1', async () => {
+ await setupGraphQL({
+ requestComplexity: { graphQLDepth: -1 },
+ });
+ const result = await graphqlRequest(buildDeepQuery());
+ expect(result.errors).toBeUndefined();
+ });
+ });
+
+ describe('fields limit', () => {
+ it('should reject query exceeding fields limit', async () => {
+ await setupGraphQL({
+ requestComplexity: { graphQLFields: 5 },
+ });
+ const result = await graphqlRequest(buildWideQuery(10));
+ expect(result.errors).toBeDefined();
+ expect(result.errors[0].message).toMatch(
+ /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(5\)/
+ );
+ });
+
+ it('should allow query within fields limit', async () => {
+ await setupGraphQL({
+ requestComplexity: { graphQLFields: 200 },
+ });
+ const result = await graphqlRequest(buildDeepQuery());
+ expect(result.errors).toBeUndefined();
+ });
+
+ it('should allow wide query with master key', async () => {
+ await setupGraphQL({
+ requestComplexity: { graphQLFields: 5 },
+ });
+ const result = await graphqlRequest(buildWideQuery(10), {
+ ...headers,
+ 'X-Parse-Master-Key': 'test',
+ });
+ expect(result.errors).toBeUndefined();
+ });
+
+ it('should count fragment fields at each spread location', async () => {
+ // With correct counting: 2 aliases (2) + 2×edges (2) + 2×node (2) + 2×objectId from fragment (2) = 8
+ // With incorrect counting (fragment once): 2 + 2 + 2 + 1 = 7
+ // Set limit to 7 so incorrect counting passes but correct counting rejects
+ await setupGraphQL({
+ requestComplexity: { graphQLFields: 7 },
+ });
+ const result = await graphqlRequest(`
+ fragment UserFields on User { objectId }
+ {
+ a1: users { edges { node { ...UserFields } } }
+ a2: users { edges { node { ...UserFields } } }
+ }
+ `);
+ expect(result.errors).toBeDefined();
+ expect(result.errors[0].message).toMatch(
+ /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(7\)/
+ );
+ });
+
+ it('should count inline fragment fields toward depth and field limits', async () => {
+ await setupGraphQL({
+ requestComplexity: { graphQLFields: 3 },
+ });
+ // Inline fragment adds fields without increasing depth:
+ // users(1) > edges(2) > ... on UserConnection { edges(3) > node(4) }
+ const result = await graphqlRequest(`{
+ users {
+ edges {
+ ... on UserEdge {
+ node {
+ objectId
+ }
+ }
+ }
+ }
+ }`);
+ expect(result.errors).toBeDefined();
+ expect(result.errors[0].message).toMatch(
+ /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(3\)/
+ );
+ });
+
+ it('should allow unlimited fields when graphQLFields is -1', async () => {
+ await setupGraphQL({
+ requestComplexity: { graphQLFields: -1 },
+ });
+ const result = await graphqlRequest(buildWideQuery(50));
+ expect(result.errors).toBeUndefined();
+ });
+ });
+});
diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js
index a767f413b3..b0bf131ef8 100644
--- a/spec/ParseGraphQLServer.spec.js
+++ b/spec/ParseGraphQLServer.spec.js
@@ -9242,6 +9242,12 @@ describe('ParseGraphQLServer', () => {
});
it_only_db('mongo')('should support deep nested creation', async () => {
+ parseServer = await global.reconfigureServer({
+ maintenanceKey: 'test2',
+ maxUploadSize: '1kb',
+ requestComplexity: { includeDepth: 10 },
+ });
+ await createGQLFromParseServer(parseServer);
const team = new Parse.Object('Team');
team.set('name', 'imATeam1');
await team.save();
diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js
new file mode 100644
index 0000000000..69a66ccd2a
--- /dev/null
+++ b/spec/RequestComplexity.spec.js
@@ -0,0 +1,327 @@
+'use strict';
+
+const Config = require('../lib/Config');
+const auth = require('../lib/Auth');
+const rest = require('../lib/rest');
+
+describe('request complexity', () => {
+ function buildNestedInQuery(depth, className = '_User') {
+ let where = {};
+ for (let i = 0; i < depth; i++) {
+ where = { field: { $inQuery: { className, where } } };
+ }
+ return where;
+ }
+
+ function buildNestedNotInQuery(depth, className = '_User') {
+ let where = {};
+ for (let i = 0; i < depth; i++) {
+ where = { field: { $notInQuery: { className, where } } };
+ }
+ return where;
+ }
+
+ function buildNestedSelect(depth, className = '_User') {
+ let where = {};
+ for (let i = 0; i < depth; i++) {
+ where = { field: { $select: { query: { className, where }, key: 'objectId' } } };
+ }
+ return where;
+ }
+
+ function buildNestedDontSelect(depth, className = '_User') {
+ let where = {};
+ for (let i = 0; i < depth; i++) {
+ where = { field: { $dontSelect: { query: { className, where }, key: 'objectId' } } };
+ }
+ return where;
+ }
+
+ describe('config validation', () => {
+ it('should accept valid requestComplexity config', async () => {
+ await expectAsync(
+ reconfigureServer({
+ requestComplexity: {
+ includeDepth: 10,
+ includeCount: 100,
+ subqueryDepth: 5,
+ graphQLDepth: 15,
+ graphQLFields: 300,
+ },
+ })
+ ).toBeResolved();
+ });
+
+ it('should accept -1 to disable a specific limit', async () => {
+ await expectAsync(
+ reconfigureServer({
+ requestComplexity: {
+ includeDepth: -1,
+ includeCount: -1,
+ subqueryDepth: -1,
+ graphQLDepth: -1,
+ graphQLFields: -1,
+ },
+ })
+ ).toBeResolved();
+ });
+
+ it('should reject value of 0', async () => {
+ await expectAsync(
+ reconfigureServer({
+ requestComplexity: { includeDepth: 0 },
+ })
+ ).toBeRejectedWith(
+ new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.')
+ );
+ });
+
+ it('should reject non-integer values', async () => {
+ await expectAsync(
+ reconfigureServer({
+ requestComplexity: { includeDepth: 3.5 },
+ })
+ ).toBeRejectedWith(
+ new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.')
+ );
+ });
+
+ it('should reject unknown properties', async () => {
+ await expectAsync(
+ reconfigureServer({
+ requestComplexity: { unknownProp: 5 },
+ })
+ ).toBeRejectedWith(
+ new Error("requestComplexity contains unknown property 'unknownProp'.")
+ );
+ });
+
+ it('should reject non-object values', async () => {
+ await expectAsync(
+ reconfigureServer({
+ requestComplexity: 'invalid',
+ })
+ ).toBeRejectedWith(new Error('requestComplexity must be an object.'));
+ });
+
+ it('should apply defaults for missing properties', async () => {
+ await reconfigureServer({
+ requestComplexity: { includeDepth: 3 },
+ });
+ const config = Config.get('test');
+ expect(config.requestComplexity.includeDepth).toBe(3);
+ expect(config.requestComplexity.includeCount).toBe(50);
+ expect(config.requestComplexity.subqueryDepth).toBe(5);
+ expect(config.requestComplexity.graphQLDepth).toBe(50);
+ expect(config.requestComplexity.graphQLFields).toBe(200);
+ });
+
+ it('should apply full defaults when not configured', async () => {
+ await reconfigureServer({});
+ const config = Config.get('test');
+ expect(config.requestComplexity).toEqual({
+ includeDepth: 5,
+ includeCount: 50,
+ subqueryDepth: 5,
+ graphQLDepth: 50,
+ graphQLFields: 200,
+ });
+ });
+ });
+
+ describe('subquery depth', () => {
+ let config;
+
+ beforeEach(async () => {
+ await reconfigureServer({
+ requestComplexity: { subqueryDepth: 3 },
+ });
+ config = Config.get('test');
+ });
+
+ it('should allow $inQuery within depth limit', async () => {
+ const where = buildNestedInQuery(3);
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', where)
+ ).toBeResolved();
+ });
+
+ it('should reject $inQuery exceeding depth limit', async () => {
+ const where = buildNestedInQuery(4);
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', where)
+ ).toBeRejectedWith(
+ jasmine.objectContaining({
+ message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
+ })
+ );
+ });
+
+ it('should reject $notInQuery exceeding depth limit', async () => {
+ const where = buildNestedNotInQuery(4);
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', where)
+ ).toBeRejectedWith(
+ jasmine.objectContaining({
+ message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
+ })
+ );
+ });
+
+ it('should reject $select exceeding depth limit', async () => {
+ const where = buildNestedSelect(4);
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', where)
+ ).toBeRejectedWith(
+ jasmine.objectContaining({
+ message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
+ })
+ );
+ });
+
+ it('should reject $dontSelect exceeding depth limit', async () => {
+ const where = buildNestedDontSelect(4);
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', where)
+ ).toBeRejectedWith(
+ jasmine.objectContaining({
+ message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
+ })
+ );
+ });
+
+ it('should allow subqueries with master key even when exceeding limit', async () => {
+ const where = buildNestedInQuery(4);
+ await expectAsync(
+ rest.find(config, auth.master(config), '_User', where)
+ ).toBeResolved();
+ });
+
+ it('should allow subqueries with maintenance key even when exceeding limit', async () => {
+ const where = buildNestedInQuery(4);
+ await expectAsync(
+ rest.find(config, auth.maintenance(config), '_User', where)
+ ).toBeResolved();
+ });
+
+ it('should allow unlimited subqueries when subqueryDepth is -1', async () => {
+ await reconfigureServer({
+ requestComplexity: { subqueryDepth: -1 },
+ });
+ config = Config.get('test');
+ const where = buildNestedInQuery(15);
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', where)
+ ).toBeResolved();
+ });
+ });
+
+ describe('include limits', () => {
+ let config;
+
+ beforeEach(async () => {
+ await reconfigureServer({
+ requestComplexity: { includeDepth: 3, includeCount: 5 },
+ });
+ config = Config.get('test');
+ });
+
+ it('should allow include within depth limit', async () => {
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c' })
+ ).toBeResolved();
+ });
+
+ it('should reject include exceeding depth limit', async () => {
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d' })
+ ).toBeRejectedWith(
+ jasmine.objectContaining({
+ message: jasmine.stringMatching(/Include depth of 4 exceeds maximum allowed depth of 3/),
+ })
+ );
+ });
+
+ it('should allow include count within limit', async () => {
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e' })
+ ).toBeResolved();
+ });
+
+ it('should reject include count exceeding limit', async () => {
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e,f' })
+ ).toBeRejectedWith(
+ jasmine.objectContaining({
+ message: jasmine.stringMatching(/Number of include fields \(\d+\) exceeds maximum allowed \(5\)/),
+ })
+ );
+ });
+
+ it('should allow includeAll when within count limit', async () => {
+ const schema = new Parse.Schema('IncludeTestClass');
+ schema.addPointer('ptr1', '_User');
+ schema.addPointer('ptr2', '_User');
+ schema.addPointer('ptr3', '_User');
+ await schema.save();
+
+ const obj = new Parse.Object('IncludeTestClass');
+ await obj.save();
+
+ await expectAsync(
+ rest.find(config, auth.nobody(config), 'IncludeTestClass', {}, { includeAll: true })
+ ).toBeResolved();
+ });
+
+ it('should reject includeAll when exceeding count limit', async () => {
+ await reconfigureServer({
+ requestComplexity: { includeDepth: 3, includeCount: 2 },
+ });
+ config = Config.get('test');
+
+ const schema = new Parse.Schema('IncludeTestClass2');
+ schema.addPointer('ptr1', '_User');
+ schema.addPointer('ptr2', '_User');
+ schema.addPointer('ptr3', '_User');
+ await schema.save();
+
+ const obj = new Parse.Object('IncludeTestClass2');
+ await obj.save();
+
+ await expectAsync(
+ rest.find(config, auth.nobody(config), 'IncludeTestClass2', {}, { includeAll: true })
+ ).toBeRejectedWith(
+ jasmine.objectContaining({
+ message: jasmine.stringMatching(/Number of include fields .* exceeds maximum allowed/),
+ })
+ );
+ });
+
+ it('should allow includes with master key even when exceeding limits', async () => {
+ await expectAsync(
+ rest.find(config, auth.master(config), '_User', {}, { include: 'a.b.c.d' })
+ ).toBeResolved();
+ });
+
+ it('should allow unlimited depth when includeDepth is -1', async () => {
+ await reconfigureServer({
+ requestComplexity: { includeDepth: -1 },
+ });
+ config = Config.get('test');
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d.e.f.g' })
+ ).toBeResolved();
+ });
+
+ it('should allow unlimited count when includeCount is -1', async () => {
+ await reconfigureServer({
+ requestComplexity: { includeCount: -1 },
+ });
+ config = Config.get('test');
+ const includes = Array.from({ length: 100 }, (_, i) => `field${i}`).join(',');
+ await expectAsync(
+ rest.find(config, auth.nobody(config), '_User', {}, { include: includes })
+ ).toBeResolved();
+ });
+ });
+});
diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js
index fb5370d759..9418f856fd 100644
--- a/spec/RestQuery.spec.js
+++ b/spec/RestQuery.spec.js
@@ -390,6 +390,7 @@ describe('rest query', () => {
});
it('battle test parallel include with 100 nested includes', async () => {
+ await reconfigureServer({ requestComplexity: { includeCount: 200 } });
const RootObject = Parse.Object.extend('RootObject');
const Level1Object = Parse.Object.extend('Level1Object');
const Level2Object = Parse.Object.extend('Level2Object');
diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js
index aea4468da8..7983e8b030 100644
--- a/spec/SecurityCheckGroups.spec.js
+++ b/spec/SecurityCheckGroups.spec.js
@@ -43,6 +43,7 @@ describe('Security Check Groups', () => {
expect(group.checks()[2].checkState()).toBe(CheckState.success);
expect(group.checks()[4].checkState()).toBe(CheckState.success);
expect(group.checks()[5].checkState()).toBe(CheckState.success);
+ expect(group.checks()[7].checkState()).toBe(CheckState.success);
});
it('checks fail correctly', async () => {
@@ -50,6 +51,13 @@ describe('Security Check Groups', () => {
config.security.enableCheckLog = true;
config.allowClientClassCreation = true;
config.graphQLPublicIntrospection = true;
+ config.requestComplexity = {
+ includeDepth: -1,
+ includeCount: -1,
+ subqueryDepth: -1,
+ graphQLDepth: -1,
+ graphQLFields: -1,
+ };
await reconfigureServer(config);
const group = new CheckGroupServerConfig();
@@ -59,6 +67,7 @@ describe('Security Check Groups', () => {
expect(group.checks()[2].checkState()).toBe(CheckState.fail);
expect(group.checks()[4].checkState()).toBe(CheckState.fail);
expect(group.checks()[5].checkState()).toBe(CheckState.fail);
+ expect(group.checks()[7].checkState()).toBe(CheckState.fail);
});
it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => {
diff --git a/src/Config.js b/src/Config.js
index 241edf9771..766c3c59d7 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -16,6 +16,7 @@ import {
LogLevels,
PagesOptions,
ParseServerOptions,
+ RequestComplexityOptions,
SchemaOptions,
SecurityOptions,
} from './Options/Definitions';
@@ -129,6 +130,7 @@ export class Config {
allowExpiredAuthDataToken,
logLevels,
rateLimit,
+ requestComplexity,
databaseOptions,
extendSessionOnUse,
allowClientClassCreation,
@@ -169,6 +171,7 @@ export class Config {
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
this.validateRequestKeywordDenylist(requestKeywordDenylist);
this.validateRateLimit(rateLimit);
+ this.validateRequestComplexity(requestComplexity);
this.validateLogLevels(logLevels);
this.validateDatabaseOptions(databaseOptions);
this.validateCustomPages(customPages);
@@ -713,6 +716,31 @@ export class Config {
}
}
+ static validateRequestComplexity(requestComplexity) {
+ if (requestComplexity == null) {
+ return;
+ }
+ if (typeof requestComplexity !== 'object' || Array.isArray(requestComplexity)) {
+ throw new Error('requestComplexity must be an object.');
+ }
+ const validKeys = Object.keys(RequestComplexityOptions);
+ for (const key of Object.keys(requestComplexity)) {
+ if (!validKeys.includes(key)) {
+ throw new Error(`requestComplexity contains unknown property '${key}'.`);
+ }
+ }
+ for (const key of validKeys) {
+ if (requestComplexity[key] !== undefined) {
+ const value = requestComplexity[key];
+ if (!Number.isInteger(value) || (value < 1 && value !== -1)) {
+ throw new Error(`requestComplexity.${key} must be a positive integer or -1 to disable.`);
+ }
+ } else {
+ requestComplexity[key] = RequestComplexityOptions[key].default;
+ }
+ }
+ }
+
generateEmailVerifyTokenExpiresAt() {
if (!this.verifyUserEmails || !this.emailVerifyTokenValidityDuration) {
return undefined;
diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js
index 231e44f5ef..bf4848e654 100644
--- a/src/GraphQL/ParseGraphQLServer.js
+++ b/src/GraphQL/ParseGraphQLServer.js
@@ -8,6 +8,7 @@ import { execute, subscribe, GraphQLError } from 'graphql';
import { SubscriptionServer } from 'subscriptions-transport-ws';
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
import requiredParameter from '../requiredParameter';
+import { createComplexityValidationPlugin } from './helpers/queryComplexity';
import defaultLogger from '../logger';
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
@@ -113,7 +114,7 @@ class ParseGraphQLServer {
requestHeaders: ['X-Parse-Application-Id'],
},
introspection: this.config.graphQLPublicIntrospection,
- plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
+ plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
schema,
});
await apollo.start();
diff --git a/src/GraphQL/helpers/queryComplexity.js b/src/GraphQL/helpers/queryComplexity.js
new file mode 100644
index 0000000000..0057e6438a
--- /dev/null
+++ b/src/GraphQL/helpers/queryComplexity.js
@@ -0,0 +1,99 @@
+import { GraphQLError } from 'graphql';
+import logger from '../../logger';
+
+function calculateQueryComplexity(operation, fragments) {
+ let maxDepth = 0;
+ let totalFields = 0;
+
+ function visitSelectionSet(selectionSet, depth, visitedFragments) {
+ if (!selectionSet) {
+ return;
+ }
+ for (const selection of selectionSet.selections) {
+ if (selection.kind === 'Field') {
+ totalFields++;
+ const newDepth = depth + 1;
+ if (newDepth > maxDepth) {
+ maxDepth = newDepth;
+ }
+ if (selection.selectionSet) {
+ visitSelectionSet(selection.selectionSet, newDepth, visitedFragments);
+ }
+ } else if (selection.kind === 'InlineFragment') {
+ visitSelectionSet(selection.selectionSet, depth, visitedFragments);
+ } else if (selection.kind === 'FragmentSpread') {
+ const name = selection.name.value;
+ if (visitedFragments.has(name)) {
+ continue;
+ }
+ const fragment = fragments[name];
+ if (fragment) {
+ const branchVisited = new Set(visitedFragments);
+ branchVisited.add(name);
+ visitSelectionSet(fragment.selectionSet, depth, branchVisited);
+ }
+ }
+ }
+ }
+
+ visitSelectionSet(operation.selectionSet, 0, new Set());
+
+ return { depth: maxDepth, fields: totalFields };
+}
+
+function createComplexityValidationPlugin(getConfig) {
+ return {
+ requestDidStart: (requestContext) => ({
+ didResolveOperation: async () => {
+ const auth = requestContext.contextValue?.auth;
+ if (auth?.isMaster || auth?.isMaintenance) {
+ return;
+ }
+
+ const config = getConfig();
+ if (!config) {
+ return;
+ }
+
+ const { graphQLDepth, graphQLFields } = config;
+ if (graphQLDepth === -1 && graphQLFields === -1) {
+ return;
+ }
+
+ const fragments = {};
+ for (const definition of requestContext.document.definitions) {
+ if (definition.kind === 'FragmentDefinition') {
+ fragments[definition.name.value] = definition;
+ }
+ }
+
+ const { depth, fields } = calculateQueryComplexity(
+ requestContext.operation,
+ fragments
+ );
+
+ if (graphQLDepth !== -1 && depth > graphQLDepth) {
+ const message = `GraphQL query depth of ${depth} exceeds maximum allowed depth of ${graphQLDepth}`;
+ logger.warn(message);
+ throw new GraphQLError(message, {
+ extensions: {
+ http: { status: 400 },
+ },
+ });
+ }
+
+ if (graphQLFields !== -1 && fields > graphQLFields) {
+ const message = `Number of GraphQL fields (${fields}) exceeds maximum allowed (${graphQLFields})`;
+ logger.warn(message);
+ throw new GraphQLError(message, {
+ extensions: {
+ http: { status: 400 },
+ },
+ });
+ }
+ },
+ }),
+ };
+}
+
+export { calculateQueryComplexity, createComplexityValidationPlugin };
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 8e8581bd8d..43e908e8ab 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -524,6 +524,14 @@ module.exports.ParseServerOptions = {
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY',
help: 'Read-only key, which has the same capabilities as MasterKey without writes',
},
+ requestComplexity: {
+ env: 'PARSE_SERVER_REQUEST_COMPLEXITY',
+ help:
+ 'Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.',
+ action: parsers.objectParser,
+ type: 'RequestComplexityOptions',
+ default: {},
+ },
requestContextMiddleware: {
env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE',
help:
@@ -702,6 +710,42 @@ module.exports.RateLimitOptions = {
default: 'ip',
},
};
+module.exports.RequestComplexityOptions = {
+ graphQLDepth: {
+ env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH',
+ help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.',
+ action: parsers.numberParser('graphQLDepth'),
+ default: 50,
+ },
+ graphQLFields: {
+ env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS',
+ help:
+ 'Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.',
+ action: parsers.numberParser('graphQLFields'),
+ default: 200,
+ },
+ includeCount: {
+ env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_COUNT',
+ help:
+ 'Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.',
+ action: parsers.numberParser('includeCount'),
+ default: 50,
+ },
+ includeDepth: {
+ env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_DEPTH',
+ help:
+ 'Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.',
+ action: parsers.numberParser('includeDepth'),
+ default: 5,
+ },
+ subqueryDepth: {
+ env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_DEPTH',
+ help:
+ 'Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.',
+ action: parsers.numberParser('subqueryDepth'),
+ default: 5,
+ },
+};
module.exports.SecurityOptions = {
checkGroups: {
env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS',
diff --git a/src/Options/docs.js b/src/Options/docs.js
index de9cef9076..d0235dad05 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -92,6 +92,7 @@
* @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications
* @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 user case.
* @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes
+ * @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.
* @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
@@ -126,6 +127,15 @@
* @property {String} zone The type of rate limit to apply. The following types are supported: