Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
569 changes: 569 additions & 0 deletions spec/ParseGraphQLQueryComplexity.spec.js

Large diffs are not rendered by default.

648 changes: 648 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,
maxQueryComplexity,
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(maxQueryComplexity, maxGraphQLQueryComplexity);
}

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

static validateQueryComplexityOptions(maxQueryComplexity, maxGraphQLQueryComplexity) {
if (maxQueryComplexity && maxGraphQLQueryComplexity) {
if (maxQueryComplexity.depth >= maxGraphQLQueryComplexity.depth) {
throw new Error('maxQueryComplexity.depth must be less than maxGraphQLQueryComplexity.depth');
}
if (maxQueryComplexity.fields >= maxGraphQLQueryComplexity.fields) {
throw new Error('maxQueryComplexity.fields 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
107 changes: 107 additions & 0 deletions src/GraphQL/helpers/queryComplexity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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
* @returns {{ depth: number, fields: number }} Maximum depth and total fields
*/
function calculateQueryComplexity(document) {
const operationAST = getOperationAST(document);
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);
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 } = 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
const { depth, fields } = calculateQueryComplexity(document);

// Validate fields (field count)
if (maxGraphQLQueryComplexity.fields && fields > maxGraphQLQueryComplexity.fields) {
throw new GraphQLError(
`Number of fields selected exceeds maximum allowed`,
);
}

// Validate maximum depth
if (maxGraphQLQueryComplexity.depth && depth > maxGraphQLQueryComplexity.depth) {
throw new GraphQLError(
`Query depth exceeds maximum allowed depth`,
);
}
},
}),
Comment on lines +9 to +105
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Pass the resolved operation into complexity calculation

With multi-operation documents getOperationAST(document) returns null unless we supply the selected operationName. In that case the function exits early, so the complexity limits are never enforced. A client can therefore bundle a cheap “decoy” operation first in the document, set operationName to the expensive one, and bypass the limiter entirely. Please feed the resolved operation into the calculation—e.g. pass requestContext.request.operationName (or reuse requestContext.operation) to calculateQueryComplexity and update that helper to respect it—so multi-operation requests cannot skip validation.

🤖 Prompt for AI Agents
In src/GraphQL/helpers/queryComplexity.js around lines 9 to 105,
calculateQueryComplexity is called with only the parsed document which lets
multi-operation documents bypass validation because getOperationAST(document)
returns null unless the selected operationName is supplied; change the plugin to
pass the resolved operation (e.g. pass requestContext.request.operationName or
requestContext.operation) into calculateQueryComplexity and update the helper to
accept either an operationName or an OperationDefinition node and call
getOperationAST(document, operationName) (or use the provided
OperationDefinition directly) so the complexity calculation always targets the
actual requested operation; ensure the helper falls back safely and still
returns {depth:0, fields:0} for missing inputs.

};
}
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,12 @@ 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 operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.',
action: parsers.objectParser,
},
Comment on lines +399 to +404
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fix the maxGraphQLQueryComplexity fields description

The new help text says the fields limit controls the number of operations, but the implementation (and tests expecting “Number of fields selected exceeds maximum allowed”) enforces the number of field selections. That discrepancy will mislead operators configuring the option. Please update the copy to accurately describe the field-selection limit.

-      'Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.',
+      'Maximum query complexity for GraphQL queries. Controls depth and number of selected fields.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of field selections in a single request* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
maxGraphQLQueryComplexity: {
env: 'PARSE_SERVER_MAX_GRAPH_QLQUERY_COMPLEXITY',
help:
'Maximum query complexity for GraphQL queries. Controls depth and number of operations.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of operations (queries/mutations) in a single request* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.',
action: parsers.objectParser,
},
maxGraphQLQueryComplexity: {
env: 'PARSE_SERVER_MAX_GRAPH_QLQUERY_COMPLEXITY',
help:
'Maximum query complexity for GraphQL queries. Controls depth and number of selected fields.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested field selections* - fields: Maximum number of field selections in a single request* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.',
action: parsers.objectParser,
},
🤖 Prompt for AI Agents
In src/Options/Definitions.js around lines 399 to 404, the help text for
maxGraphQLQueryComplexity incorrectly states that "fields" limits the number of
operations; update the copy so it accurately reflects the implementation and
tests by describing "fields" as the maximum number of field selections in a
single request (e.g., "fields: Maximum number of field selections in a single
request") and adjust surrounding wording to reference field selections rather
than operations so operators are not misled.

maxLimit: {
env: 'PARSE_SERVER_MAX_LIMIT',
help: 'Max value for limit option on queries, defaults to unlimited',
Expand All @@ -407,6 +413,12 @@ module.exports.ParseServerOptions = {
"Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null)",
action: parsers.numberOrStringParser('maxLogFiles'),
},
maxQueryComplexity: {
env: 'PARSE_SERVER_MAX_QUERY_COMPLEXITY',
help:
'Maximum query complexity for REST API includes. Controls depth and number of include fields.* Format: { depth: number, fields: number }* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)* - fields: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.',
action: parsers.objectParser,
},
maxUploadSize: {
env: 'PARSE_SERVER_MAX_UPLOAD_SIZE',
help: 'Max file size for uploads, defaults to 20mb',
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.

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

export interface ParseServerOptions {
/* Your Parse Application ID
Expand Down Expand Up @@ -347,6 +351,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, fields: number }
* - depth: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3)
* - fields: Maximum number of include fields (e.g., foo,bar,baz = 3 fields)
* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values
* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.
*/
maxQueryComplexity: ?QueryComplexityOptions;
/* Maximum query complexity for GraphQL queries. Controls depth and number of operations.
* Format: { depth: number, fields: number }
* - depth: Maximum depth of nested field selections
* - fields: Maximum number of operations (queries/mutations) in a single request
* If both maxQueryComplexity and maxGraphQLQueryComplexity are provided, maxQueryComplexity values
* must be lower than maxGraphQLQueryComplexity values to avoid validation conflicts.
*/
maxGraphQLQueryComplexity: ?QueryComplexityOptions;
Comment on lines +354 to +369
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Clarify the meaning of fields in the public type

The public option comment repeats that fields controls “number of operations”, which conflicts with the actual behaviour (it caps field selections). This will mislead integrators using the type definitions. Please align the comment with the implementation and state that it limits the number of fields (include paths for REST / field selections for GraphQL).

🧰 Tools
🪛 Biome (2.1.2)

[error] 362-362: Expected a statement but instead found '?'.

Expected a statement here.

(parse)

🤖 Prompt for AI Agents
In src/Options/index.js around lines 354 to 369, the JSDoc for
QueryComplexityOptions incorrectly describes "fields" as limiting the number of
"operations" for GraphQL; update both comment blocks so "fields" is described
consistently with the implementation — it limits the number of field selections
(for GraphQL) and include paths (for REST), not operations, and ensure both
maxQueryComplexity and maxGraphQLQueryComplexity descriptions use the same
wording and examples.

}

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 maxQueryComplexity is configured for non-master users
if (
!this.auth.isMaster &&
!this.auth.isMaintenance &&
this.config.maxQueryComplexity &&
(this.config.maxQueryComplexity.depth || this.config.maxQueryComplexity.fields)
) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
'includeAll is not allowed when query complexity limits are configured'
);
}
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 maxQueryComplexity is configured for non-master users
if (
!this.auth.isMaster &&
!this.auth.isMaintenance &&
this.config.maxQueryComplexity &&
(this.config.maxQueryComplexity.depth || this.config.maxQueryComplexity.fields)
) {
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.maxQueryComplexity && this.include && this.include.length > 0) {
const fieldsCount = this.include.length;

if (this.config.maxQueryComplexity.fields && fieldsCount > this.config.maxQueryComplexity.fields) {
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.maxQueryComplexity.depth && depth > this.config.maxQueryComplexity.depth) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
`Include depth exceeds maximum allowed`
);
Comment on lines +299 to +314
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Count requested include paths, not internal expansion nodes

this.include.length is counting the internal expansion order, so include=foo.bar becomes ['foo'] and ['foo','bar'] and we report two fields instead of the single user request. That immediately trips the fields cap even when the client stays within the documented limit. Please derive the count from the actual include strings (e.g. the deduped paths set before we explode them into traversal order, or from restOptions.include after normalisation) so nested includes don’t get double-counted. Otherwise legitimate requests will start failing as soon as limits are enabled.

}
}
}

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