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 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 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 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 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?

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm, the include protection is done early when the RestQuery is created, so it no longer prevents abuse. Basically, the system works like GraphQL selection validation: you need to explicitly declare what you want if you need pointers (to avoid abuse on N+1 queries).

In the case of includeAll, it means we allow someone to say “Give me everything you have and I’ll see if it passes.” So objects are pulled from the DB, and we check at each stage whether the limit has been reached.

I’m not sure this is a best practice for production, nor whether we should encourage this without the master key.

Let me know what you think. Currently, I believe includeAll should be disabled when max complexity is enabled, but we agreed that this would be a breaking change.

Copy link
Member

Choose a reason for hiding this comment

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

@coderabbitai If maxIncludeQueryComplexity is enabled, how can someone achieve a functionality similar to includeAll?

Copy link

Choose a reason for hiding this comment

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

@mtrezza, when maxIncludeQueryComplexity is enabled and includeAll is blocked, users would need to explicitly specify the fields they want to include using the include parameter, which would still be subject to the configured complexity limits.

For example, instead of:

// Using includeAll (blocked when limits are configured)
query.includeAll();

They would need to do:

// Explicitly listing fields (subject to complexity limits)
query.include(['author', 'category', 'comments']);

Current implementation rationale:
The current approach blocks includeAll entirely when complexity limits are configured because:

  1. includeAll automatically includes all Pointer/Array fields from the schema without knowing in advance how many there are
  2. This could inadvertently bypass the complexity limits if validation happens after schema resolution
  3. It forces developers to be explicit about what they're including, making it harder to accidentally create expensive queries

Potential improvement:
However, your original suggestion has merit. A more user-friendly approach could be:

  1. When includeAll is used with complexity limits enabled, first resolve the schema to determine which fields would be included
  2. Count the resulting fields and calculate the depth
  3. If within limits, proceed; if exceeding limits, throw Parse.Error.INVALID_QUERY with a clear message about which limit was exceeded
  4. This would provide the convenience of includeAll while still enforcing the safety limits

This would require refactoring to evaluate the schema during the options parsing phase rather than blocking includeAll outright. What do you think about this approach?


🧠 Learnings used
Learnt from: EmpiDev
Repo: parse-community/parse-server PR: 9770
File: spec/CloudCode.spec.js:446-469
Timestamp: 2025-08-26T14:06:31.853Z
Learning: In the Parse Server codebase, when handling query objects in maybeRunAfterFindTrigger, objects without a where property that contain options like limit/skip should be treated as query JSON with an empty where clause using the spread pattern { where: {}, ...query }, not nested as { where: query }.

Learnt from: mtrezza
Repo: parse-community/parse-server PR: 0
File: :0-0
Timestamp: 2025-11-08T13:46:04.940Z
Learning: When reviewing Parse Server PRs that add new features, always check whether the feature is documented in the README.md file, though for new Parse Server options this is optional rather than required.

Learnt from: mtrezza
Repo: parse-community/parse-server PR: 0
File: :0-0
Timestamp: 2025-11-17T15:02:48.786Z
Learning: For Parse Server PRs, always suggest an Angular commit convention PR title that would make a meaningful changelog entry for developers. Update the PR title suggestion on every commit. The format should be: type(scope): description. Common types include feat, fix, perf, refactor, docs, test, chore. The scope should identify the subsystem (e.g., graphql, rest, push, security). The description should be action-oriented and clearly convey the change's impact to developers.

Copy link
Member

Choose a reason for hiding this comment

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

@coderabbitai The docs say that includeAll includes all nested Parse.Objects one level deep.

Is that true in code, and if so, why does includeAll need to be restricted if maxIncludeQueryComplexity is set?

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for documentation about includeAll behavior
rg -n -C3 "includeAll" README.md docs/ --type md 2>/dev/null || echo "No markdown docs found for includeAll"

# Search for includeAll in test files to understand expected behavior
rg -n -C3 "includeAll" spec/ --type js -g "*.spec.js" | head -100

Length of output: 5592


🏁 Script executed:

#!/bin/bash
# Look at the handleIncludeAll implementation more closely
ast-grep --pattern $'handleIncludeAll = function() {
  $$$
}'

# Also check if there are any comments about the depth behavior
rg -n "one level deep" --type js

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for Parse SDK documentation about includeAll
rg -n "includeAll" --type js -C5 src/ | grep -A5 -B5 "comment\|@param\|@return\|//"

Length of output: 2731


🏁 Script executed:

#!/bin/bash
# Check Parse JS SDK for includeAll documentation
cat << 'EOF' > /tmp/check_parse_docs.py
import urllib.request
import json

# Try to fetch Parse JS SDK docs about includeAll
urls = [
    "https://parseplatform.org/Parse-SDK-JS/api/master/Parse.Query.html",
    "https://docs.parseplatform.org/js/guide/#queries"
]

for url in urls:
    try:
        print(f"Checking: {url}")
    except Exception as e:
        print(f"Could not fetch: {e}")
EOF

python /tmp/check_parse_docs.py

Length of output: 135


🌐 Web query:

Parse SDK includeAll method documentation one level deep

💡 Result:

Short answer

  • Parse.Query.includeAll() tells the SDK to fetch and populate every pointer/relation field on the returned objects, but only one level deep — it includes the immediate related objects, not the pointers inside those included objects. [1][2]

Details (one-level deep)

  • What it does: when you run query.includeAll(), the query result’s pointer/relation columns are replaced with the full Parse.Object(s) for those keys. This is equivalent to calling include('*') for the top-level object. [1]
  • Depth: only the immediate pointers/relations on the objects returned by that query are included. If an included object itself has pointer fields, those are not automatically fetched (no recursive inclusion). Use explicit include('a.b') or additional includes if you need deeper levels. [1][2]
  • Requirement: requires Parse Server >= 3.0.0 (documented in the SDK reference). [1]

Example (JS)

  • const q = new Parse.Query('Post'); q.includeAll(); const results = await q.find(); // each Post’s pointer columns are populated one level deep. [1]

Sources

  • Parse JS SDK API reference — Parse.Query.includeAll(). [1]
  • Parse JavaScript Guide / API reference (include behavior). [2]

@mtrezza, excellent catch! You're absolutely right to question this restriction.

The documentation is correct: includeAll does include all nested Parse.Objects one level deep, and the code confirms this.

Looking at the handleIncludeAll implementation (lines 856-878):

for (const field in schema.fields) {
  if (
    (schema.fields[field].type && schema.fields[field].type === 'Pointer') ||
    (schema.fields[field].type && schema.fields[field].type === 'Array')
  ) {
    includeFields.push([field]);  // Single-element array = depth 1
    keyFields.push(field);
  }
}

Each field is pushed as a single-element array, meaning the depth is always 1.

Why the restriction exists (but may be overly restrictive):

The current implementation blocks includeAll preemptively at lines 210-218 (during option parsing) before the schema is resolved. However, the actual validation happens later at lines 321-338:

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`);
}

Since includeAll always produces depth = 1, the depth check would always pass if maxIncludeQueryComplexity.depth ≥ 1.

Conclusion:

The restriction is overly broad. A better approach would be:

  1. Allow includeAll even when maxIncludeQueryComplexity is configured
  2. Let it resolve the schema and populate the includes
  3. Then validate against the limits:
    • Depth check would always pass (depth = 1)
    • Count check could fail if the schema has more Pointer/Array fields than maxIncludeQueryComplexity.count

This would provide the convenience of includeAll while still enforcing safety limits where they actually matter (field count, not depth).


🧠 Learnings used
Learnt from: EmpiDev
Repo: parse-community/parse-server PR: 9770
File: spec/CloudCode.spec.js:446-469
Timestamp: 2025-08-26T14:06:31.853Z
Learning: In the Parse Server codebase, when handling query objects in maybeRunAfterFindTrigger, objects without a where property that contain options like limit/skip should be treated as query JSON with an empty where clause using the spread pattern { where: {}, ...query }, not nested as { where: query }.

Learnt from: mtrezza
Repo: parse-community/parse-server PR: 0
File: :0-0
Timestamp: 2025-11-08T13:46:04.940Z
Learning: When reviewing Parse Server PRs that add new features, always check whether the feature is documented in the README.md file, though for new Parse Server options this is optional rather than required.

Learnt from: mtrezza
Repo: parse-community/parse-server PR: 0
File: :0-0
Timestamp: 2025-11-17T15:02:48.786Z
Learning: For Parse Server PRs, always suggest an Angular commit convention PR title that would make a meaningful changelog entry for developers. Update the PR title suggestion on every commit. The format should be: type(scope): description. Common types include feat, fix, perf, refactor, docs, test, chore. The scope should identify the subsystem (e.g., graphql, rest, push, security). The description should be action-oriented and clearly convey the change's impact to developers.

Learnt from: mtrezza
Repo: parse-community/parse-server PR: 0
File: :0-0
Timestamp: 2025-11-08T13:46:04.940Z
Learning: For new Parse Server options, verify that the option is documented in src/Options/index.js and that npm run definitions has been executed to reflect changes in src/Options/docs.js and src/Options/Definitions.js. README.md documentation is a bonus but not required for new options.

Copy link
Member

@mtrezza mtrezza Nov 18, 2025

Choose a reason for hiding this comment

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

@Moumouls I confirmed that behavior in #9936. I think we can lift the restriction and only block includeAll if maxIncludeQueryComplexity allows a depth of 0, i.e. no depth at all. Not sure if you considered this edge case; is depth 0 valid?

);
}
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