Skip to content
664 changes: 664 additions & 0 deletions spec/ParseGraphQLQueryComplexity.spec.js

Large diffs are not rendered by default.

654 changes: 654 additions & 0 deletions spec/RestQuery.spec.js

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ export class Config {
databaseOptions,
extendSessionOnUse,
allowClientClassCreation,
maxIncludeQueryComplexity,
maxGraphQLQueryComplexity,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
Expand Down Expand Up @@ -173,6 +175,7 @@ export class Config {
this.validateDatabaseOptions(databaseOptions);
this.validateCustomPages(customPages);
this.validateAllowClientClassCreation(allowClientClassCreation);
this.validateQueryComplexityOptions(maxIncludeQueryComplexity, maxGraphQLQueryComplexity);
}

static validateCustomPages(customPages) {
Expand Down Expand Up @@ -230,6 +233,17 @@ export class Config {
}
}

static validateQueryComplexityOptions(maxIncludeQueryComplexity, maxGraphQLQueryComplexity) {
if (maxIncludeQueryComplexity && maxGraphQLQueryComplexity) {
if (maxIncludeQueryComplexity.depth >= maxGraphQLQueryComplexity.depth) {
throw new Error('maxIncludeQueryComplexity.depth must be less than maxGraphQLQueryComplexity.depth');
}
if (maxIncludeQueryComplexity.count >= maxGraphQLQueryComplexity.fields) {
throw new Error('maxIncludeQueryComplexity.count must be less than maxGraphQLQueryComplexity.fields');
}
}
}

static validateSecurityOptions(security) {
if (Object.prototype.toString.call(security) !== '[object Object]') {
throw 'Parse Server option security must be an object.';
Expand Down
13 changes: 12 additions & 1 deletion src/GraphQL/ParseGraphQLServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import requiredParameter from '../requiredParameter';
import defaultLogger from '../logger';
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
import { createComplexityValidationPlugin } from './helpers/queryComplexity';


const IntrospectionControlPlugin = (publicIntrospection) => ({
Expand Down Expand Up @@ -98,14 +99,24 @@ class ParseGraphQLServer {
return this._server;
}
const { schema, context } = await this._getGraphQLOptions();
const plugins = [
ApolloServerPluginCacheControlDisabled(),
IntrospectionControlPlugin(this.config.graphQLPublicIntrospection),
];

// Add complexity validation plugin if configured
if (this.parseServer.config.maxGraphQLQueryComplexity) {
plugins.push(createComplexityValidationPlugin(this.parseServer.config));
}

const apollo = new ApolloServer({
csrfPrevention: {
// See https://www.apollographql.com/docs/router/configuration/csrf/
// needed since we use graphql upload
requestHeaders: ['X-Parse-Application-Id'],
},
introspection: this.config.graphQLPublicIntrospection,
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
plugins,
schema,
});
await apollo.start();
Expand Down
127 changes: 127 additions & 0 deletions src/GraphQL/helpers/queryComplexity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { GraphQLError, getOperationAST, Kind } from 'graphql';

/**
* Calculate the maximum depth and fields (field count) of a GraphQL query
* @param {DocumentNode} document - The GraphQL document AST
* @param {string} operationName - Optional operation name to select from multi-operation documents
* @param {Object} maxLimits - Optional maximum limits for early exit optimization
* @param {number} maxLimits.depth - Maximum depth allowed
* @param {number} maxLimits.fields - Maximum fields allowed
* @returns {{ depth: number, fields: number }} Maximum depth and total fields
*/
function calculateQueryComplexity(document, operationName, maxLimits = {}) {
const operationAST = getOperationAST(document, operationName);
if (!operationAST || !operationAST.selectionSet) {
return { depth: 0, fields: 0 };
}

// Build fragment definition map
const fragments = {};
if (document.definitions) {
document.definitions.forEach(def => {
if (def.kind === Kind.FRAGMENT_DEFINITION) {
fragments[def.name.value] = def;
}
});
}

let maxDepth = 0;
let fields = 0;

function visitSelectionSet(selectionSet, depth) {
if (!selectionSet || !selectionSet.selections) {
return;
}

selectionSet.selections.forEach(selection => {
if (selection.kind === Kind.FIELD) {
fields++;
maxDepth = Math.max(maxDepth, depth);

// Early exit optimization: throw immediately if limits are exceeded
if (maxLimits.fields && fields > maxLimits.fields) {
throw new GraphQLError(
`Number of fields selected exceeds maximum allowed`,
{
extensions: {
http: {
status: 403,
},
}
}
);
}

if (maxLimits.depth && maxDepth > maxLimits.depth) {
throw new GraphQLError(
`Query depth exceeds maximum allowed depth`,
{
extensions: {
http: {
status: 403,
},
}
}
);
}

if (selection.selectionSet) {
visitSelectionSet(selection.selectionSet, depth + 1);
}
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
// Inline fragments don't add depth, just traverse their selections
visitSelectionSet(selection.selectionSet, depth);
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
const fragmentName = selection.name.value;
const fragment = fragments[fragmentName];
// Note: Circular fragments are already prevented by GraphQL validation (NoFragmentCycles rule)
// so we don't need to check for cycles here
if (fragment && fragment.selectionSet) {
visitSelectionSet(fragment.selectionSet, depth);
}
}
});
}

visitSelectionSet(operationAST.selectionSet, 1);
return { depth: maxDepth, fields };
}

/**
* Create a GraphQL complexity validation plugin for Apollo Server
* Computes depth and total field count directly from the parsed GraphQL document
* @param {Object} config - Parse Server config object
* @returns {Object} Apollo Server plugin
*/
export function createComplexityValidationPlugin(config) {
return {
requestDidStart: () => ({
didResolveOperation: async (requestContext) => {
const { document, operationName } = requestContext;
const auth = requestContext.contextValue?.auth;

// Skip validation for master/maintenance keys
if (auth?.isMaster || auth?.isMaintenance) {
return;
}

// Skip if no complexity limits are configured
if (!config.maxGraphQLQueryComplexity) {
return;
}

// Skip if document is not available
if (!document) {
return;
}

const maxGraphQLQueryComplexity = config.maxGraphQLQueryComplexity;

// Calculate depth and fields in a single pass for performance
// Pass max limits for early exit optimization - will throw immediately if exceeded
// SECURITY: operationName is crucial for multi-operation documents to validate the correct operation
calculateQueryComplexity(document, operationName, maxGraphQLQueryComplexity);
},
}),
};
}
12 changes: 12 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,18 @@ module.exports.ParseServerOptions = {
'(Optional) The duration in seconds for which the current `masterKey` is being used before it is requested again if `masterKey` is set to a function. If `masterKey` is not set to a function, this option has no effect. Default is `0`, which means the master key is requested by invoking the `masterKey` function every time the master key is used internally by Parse Server.',
action: parsers.numberParser('masterKeyTtl'),
},
maxGraphQLQueryComplexity: {
env: 'PARSE_SERVER_MAX_GRAPH_QLQUERY_COMPLEXITY',
help:
'Maximum query complexity for GraphQL queries. Controls depth and number of field selections.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of field selections in a single request* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.',
action: parsers.objectParser,
},
maxIncludeQueryComplexity: {
env: 'PARSE_SERVER_MAX_INCLUDE_QUERY_COMPLEXITY',
help:
'Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, count: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.',
action: parsers.objectParser,
},
maxLimit: {
env: 'PARSE_SERVER_MAX_LIMIT',
help: 'Max value for limit option on queries, defaults to unlimited',
Expand Down
2 changes: 2 additions & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ type RequestKeywordDenylist = {
key: string | any,
value: any,
};
type GraphQLQueryComplexityOptions = {
depth?: number,
fields?: number,
};
type IncludeComplexityOptions = {
depth?: number,
count?: number,
};

export interface ParseServerOptions {
/* Your Parse Application ID
Expand Down Expand Up @@ -347,6 +355,22 @@ export interface ParseServerOptions {
rateLimit: ?(RateLimitOptions[]);
/* Options to customize the request context using inversion of control/dependency injection.*/
requestContextMiddleware: ?(req: any, res: any, next: any) => void;
/* Maximum query complexity for REST API includes. Controls depth and number of include fields.
* Format: { depth: number, count: number }
* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)
* - count: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)
* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values
* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.
*/
maxIncludeQueryComplexity: ?IncludeComplexityOptions;
/* Maximum query complexity for GraphQL queries. Controls depth and number of field selections.
Copy link
Member

@mtrezza mtrezza Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment format isn't supported by our parser; just like other comments it needs to use HTML tags; <br> tags for new lines and <ul><li> for lists.

* Format: { depth: number, fields: number }
* - depth: Maximum depth of nested field selections
* - fields: Maximum number of field selections in a single request
* If both maxIncludeQueryComplexity and maxGraphQLQueryComplexity are provided, maxIncludeQueryComplexity values
* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.
*/
maxGraphQLQueryComplexity: ?GraphQLQueryComplexityOptions;
}

export interface RateLimitOptions {
Expand Down
44 changes: 44 additions & 0 deletions src/RestQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,18 @@ function _UnsafeRestQuery(
this.doCount = true;
break;
case 'includeAll':
// Block includeAll if maxIncludeQueryComplexity is configured for non-master users
if (
!this.auth.isMaster &&
!this.auth.isMaintenance &&
this.config.maxIncludeQueryComplexity &&
(this.config.maxIncludeQueryComplexity.depth || this.config.maxIncludeQueryComplexity.count)
) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
'includeAll is not allowed when query complexity limits are configured'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that a sensible constraint? includeAll is a logical feature while maxIncludeQueryComplexity is a safety limit, so maybe we should treat them independently. How about allowing includeAll even if maxIncludeQueryComplexity is set, but throw Parse.Error.INVALID_QUERY error if maxIncludeQueryComplexity is hit?

);
}
this.includeAll = true;
break;
case 'explain':
Expand Down Expand Up @@ -236,6 +248,18 @@ function _UnsafeRestQuery(
case 'include': {
const paths = restOptions.include.split(',');
if (paths.includes('*')) {
// Block includeAll if maxIncludeQueryComplexity is configured for non-master users
if (
!this.auth.isMaster &&
!this.auth.isMaintenance &&
this.config.maxIncludeQueryComplexity &&
(this.config.maxIncludeQueryComplexity.depth || this.config.maxIncludeQueryComplexity.count)
) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
'includeAll is not allowed when query complexity limits are configured'
);
}
this.includeAll = true;
break;
}
Expand Down Expand Up @@ -270,6 +294,26 @@ function _UnsafeRestQuery(
throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad option: ' + option);
}
}

// Validate query complexity for REST includes
if (!this.auth.isMaster && !this.auth.isMaintenance && this.config.maxIncludeQueryComplexity && this.include && this.include.length > 0) {
const includeCount = this.include.length;

if (this.config.maxIncludeQueryComplexity.count && includeCount > this.config.maxIncludeQueryComplexity.count) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
`Number of include fields exceeds maximum allowed`
);
}

const depth = Math.max(...this.include.map(path => path.length));
if (this.config.maxIncludeQueryComplexity.depth && depth > this.config.maxIncludeQueryComplexity.depth) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
`Include depth exceeds maximum allowed`
);
}
}
}

// A convenient method to perform all the steps of processing a query
Expand Down
Loading
Loading