Skip to content
Open
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
1,036 changes: 1,036 additions & 0 deletions spec/ParseGraphQLQueryComplexity.spec.js

Large diffs are not rendered by default.

1,002 changes: 1,002 additions & 0 deletions spec/RestQuery.spec.js

Large diffs are not rendered by default.

23 changes: 23 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,26 @@ export class Config {
}
}

static validateQueryComplexityOptions(maxIncludeQueryComplexity, maxGraphQLQueryComplexity) {
if (maxIncludeQueryComplexity && maxGraphQLQueryComplexity) {
// Skip validation if either value is -1 (skip validation flag)
const includeDepth = maxIncludeQueryComplexity.depth;
const graphQLDepth = maxGraphQLQueryComplexity.depth;
const includeCount = maxIncludeQueryComplexity.count;
const graphQLFields = maxGraphQLQueryComplexity.fields;

// Validate depth only if neither is -1
if (includeDepth !== -1 && graphQLDepth !== -1 && includeDepth >= graphQLDepth) {
throw new Error('maxIncludeQueryComplexity.depth must be less than maxGraphQLQueryComplexity.depth');
}

// Validate count/fields only if neither is -1
if (includeCount !== -1 && graphQLFields !== -1 && includeCount >= graphQLFields) {
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 @@ -106,14 +107,24 @@ class ParseGraphQLServer {
const createServer = async () => {
try {
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
141 changes: 141 additions & 0 deletions src/GraphQL/helpers/queryComplexity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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;

// Filter out -1 values (skip validation flag)
const maxLimits = {};
if (maxGraphQLQueryComplexity.depth !== -1 && maxGraphQLQueryComplexity.depth !== undefined) {
maxLimits.depth = maxGraphQLQueryComplexity.depth;
}
if (maxGraphQLQueryComplexity.fields !== -1 && maxGraphQLQueryComplexity.fields !== undefined) {
maxLimits.fields = maxGraphQLQueryComplexity.fields;
}

// Skip validation if all limits are -1
if (Object.keys(maxLimits).length === 0) {
return;
}

// 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, maxLimits);
},
}),
};
}
12 changes: 12 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,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.<br><br>Format: `{ depth: number, fields: number }`<ul><li>`depth`: Maximum depth of nested field selections. Set to `-1` to skip depth validation.</li><li>`fields`: Maximum number of field selections in a single request. Set to `-1` to skip fields validation.</li></ul>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.<br><br>Format: `{ depth: number, count: number }`<ul><li>`depth`: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3). Set to `-1` to skip depth validation.</li><li>`count`: Maximum number of include fields (e.g., foo,bar,baz = 3 fields). Set to `-1` to skip count validation.</li></ul>If both `maxIncludeQueryComplexity` and `maxGraphQLQueryComplexity` are provided, `maxIncludeQueryComplexity` values must be lower than `maxGraphQLQueryComplexity` values to avoid validation conflicts.<br><br>Note: when this option is active, the `includeAll` method is not allowed.',
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.

29 changes: 29 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 @@ -350,6 +358,27 @@ export interface ParseServerOptions {
/* If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`.
:DEFAULT: true */
enableSanitizedErrorResponse: ?boolean;
/* Maximum query complexity for REST API includes. Controls depth and number of include fields.
<br><br>
Format: `{ depth: number, count: number }`
<ul>
<li>`depth`: Maximum depth of nested includes (e.g., foo.bar.baz = depth 3). Set to `-1` to skip depth validation.</li>
<li>`count`: Maximum number of include fields (e.g., foo,bar,baz = 3 fields). Set to `-1` to skip count validation.</li>
</ul>
If both `maxIncludeQueryComplexity` and `maxGraphQLQueryComplexity` are provided, `maxIncludeQueryComplexity` values must be lower than `maxGraphQLQueryComplexity` values to avoid validation conflicts.
<br><br>
Note: when this option is active, the `includeAll` method is not allowed.
*/
maxIncludeQueryComplexity: ?IncludeComplexityOptions;
/* Maximum query complexity for GraphQL queries. Controls depth and number of field selections.
<br><br>
Format: `{ depth: number, fields: number }`
<ul>
<li>`depth`: Maximum depth of nested field selections. Set to `-1` to skip depth validation.</li>
<li>`fields`: Maximum number of field selections in a single request. Set to `-1` to skip fields validation.</li>
</ul>
If both `maxIncludeQueryComplexity` and `maxGraphQLQueryComplexity` are provided, `maxIncludeQueryComplexity` values must be lower than `maxGraphQLQueryComplexity` values to avoid validation conflicts. */
maxGraphQLQueryComplexity: ?GraphQLQueryComplexityOptions;
Comment on lines +361 to +381
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

Keep includeAll policy consistent across docs, tests, and runtime.
Docs say includeAll is not allowed when limits are active, but the new REST tests expect it to be allowed for some -1 configurations. Please align one intended behavior across all three.

🧰 Tools
🪛 Biome (2.1.2)

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

Expected a statement here.

(parse)


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

Expected a statement here.

(parse)


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

Expected a statement here.

(parse)

🤖 Prompt for AI Agents
In src/Options/index.js around lines 361 to 381, the behavior of allow/deny for
includeAll is inconsistent between docs, tests, and runtime; standardize by
making includeAll allowed only when the relevant maxIncludeQueryComplexity is
effectively disabled (both depth and count are -1), and disallowed whenever any
active limit (depth or count >= 0) is set. Update the runtime validation to
treat {-1,-1} as "no limits" and permit includeAll, update unit/integration
tests to expect includeAll allowed only for the fully-disabled case, and update
the comments/docs block to state this exact rule so docs, tests, and runtime are
aligned.

}

export interface RateLimitOptions {
Expand Down
48 changes: 48 additions & 0 deletions src/RestQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,19 @@ function _UnsafeRestQuery(
this.doCount = true;
break;
case 'includeAll':
// Block includeAll if maxIncludeQueryComplexity is configured for non-master users
// Skip blocking if both limits are -1 (disabled)
if (
!this.auth.isMaster &&
!this.auth.isMaintenance &&
this.config.maxIncludeQueryComplexity &&
(this.config.maxIncludeQueryComplexity.depth > -1 || this.config.maxIncludeQueryComplexity.count > -1)
) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
'includeAll is not allowed when query complexity limits are configured'
);
}
Comment on lines 210 to +223
Copy link

@coderabbitai coderabbitai bot Dec 12, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Decide intended includeAll behavior for partial -1 configs and keep it consistent with tests/docs.
Right now any enabled limit blocks includeAll/include=* up-front, which contradicts the new tests that expect includeAll allowed when one dimension is -1. Either adjust tests/docs or change the blocking condition to match the intended semantics.

Also applies to: 252-266

🤖 Prompt for AI Agents
In src/RestQuery.js around lines 210-223 (and similarly at 252-266), the current
check blocks includeAll if any configured limit is enabled, but tests/docs
expect includeAll to be allowed when one dimension is -1; change the condition
so includeAll is only blocked when both maxIncludeQueryComplexity.depth and
.count are not -1 (i.e., both limits are enabled). Update the boolean logic to
handle undefined config safely and throw only when config exists and depth > -1
AND count > -1.

Copy link
Member Author

Choose a reason for hiding this comment

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

yes any enabled constraint will block includeAll, what should i update to make everythinkg consistent ?

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 includeAll test cases with -1 values in the REST query complexity tests
rg -n -A5 -B5 "includeAll" spec/RestQuery.spec.js | head -100

Length of output: 3443


🏁 Script executed:

#!/bin/bash
# Search for test cases involving -1 (disabled) values in complexity tests
rg -n -C8 "depth.*-1|count.*-1" spec/RestQuery.spec.js | head -150

Length of output: 5039


🏁 Script executed:

#!/bin/bash
# Check documentation for includeAll blocking behavior
rg -n -C3 "includeAll" src/Options/Definitions.js src/Options/docs.js src/Options/index.js

Length of output: 5915


@Moumouls The tests need to be updated to match your intended behavior. Currently, these two test cases expect includeAll to succeed when one dimension is -1:

Lines 1386-1411: "should allow includeAll when depth is -1"

  • Config: { depth: -1, count: 5 }
  • Currently expects: includeAll succeeds
  • Should expect: rejection with error "includeAll is not allowed when query complexity limits are configured"

Lines 1413-1438: "should allow includeAll when count is -1"

  • Config: { depth: 5, count: -1 }
  • Currently expects: includeAll succeeds
  • Should expect: rejection with error "includeAll is not allowed when query complexity limits are configured"

These tests should be updated to expect rejection instead of success, since your intent is that any enabled constraint (even with the other set to -1) should block includeAll.

You could also consider updating the documentation in src/Options/index.js (line 370) and src/Options/Definitions.js (line 416) to clarify:

Note: when this option is active (even if only one dimension is enabled), the `includeAll` method is not allowed.

This would make it clear that partial -1 configs still block includeAll.


🧠 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-12-02T06:55:53.808Z
Learning: When reviewing Parse Server PRs that add or modify Parse Server options, always verify that changes are properly reflected in three files: src/Options/index.js (where changes originate), src/Options/Definitions.js, and src/Options/docs.js. The correct workflow is: make changes in index.js first, then run `npm run definitions` to automatically replicate the changes to Definitions.js and docs.js.

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.

this.includeAll = true;
break;
case 'explain':
Expand Down Expand Up @@ -237,6 +250,19 @@ function _UnsafeRestQuery(
case 'include': {
const paths = restOptions.include.split(',');
if (paths.includes('*')) {
// Block includeAll if maxIncludeQueryComplexity is configured for non-master users
// Skip blocking if both limits are -1 (disabled)
if (
!this.auth.isMaster &&
!this.auth.isMaintenance &&
this.config.maxIncludeQueryComplexity &&
(this.config.maxIncludeQueryComplexity.depth > -1 || this.config.maxIncludeQueryComplexity.count > -1)
) {
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 @@ -271,6 +297,28 @@ 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;

// Skip count validation if set to -1
if (this.config.maxIncludeQueryComplexity.count !== -1 && 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));
// Skip depth validation if set to -1
if (this.config.maxIncludeQueryComplexity.depth !== -1 && this.config.maxIncludeQueryComplexity.depth && depth > this.config.maxIncludeQueryComplexity.depth) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
`Include depth exceeds maximum allowed`
);
}
}
Comment on lines +301 to +321
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

Bug: count: 0 / depth: 0 won’t be enforced (falsy checks), but tests expect rejection.
This currently skips validation when limit is 0. Use explicit number checks instead of truthiness.

-  if (!this.auth.isMaster && !this.auth.isMaintenance && this.config.maxIncludeQueryComplexity && this.include && this.include.length > 0) {
+  if (!this.auth.isMaster && !this.auth.isMaintenance && this.config.maxIncludeQueryComplexity && this.include?.length > 0) {
     const includeCount = this.include.length;

-    if (this.config.maxIncludeQueryComplexity.count !== -1 && this.config.maxIncludeQueryComplexity.count && includeCount > this.config.maxIncludeQueryComplexity.count) {
+    const countLimit = this.config.maxIncludeQueryComplexity.count;
+    if (typeof countLimit === 'number' && countLimit !== -1 && includeCount > countLimit) {
       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 !== -1 && this.config.maxIncludeQueryComplexity.depth && depth > this.config.maxIncludeQueryComplexity.depth) {
+    const depthLimit = this.config.maxIncludeQueryComplexity.depth;
+    if (typeof depthLimit === 'number' && depthLimit !== -1 && depth > depthLimit) {
       throw new Parse.Error(
         Parse.Error.INVALID_QUERY,
         `Include depth exceeds maximum allowed`
       );
     }
   }
🤖 Prompt for AI Agents
In src/RestQuery.js around lines 301 to 321, the current checks use truthiness
for maxIncludeQueryComplexity.count and .depth so values of 0 are skipped;
change the conditions to explicitly check for numeric limits: verify the config
field is a number (e.g. typeof this.config.maxIncludeQueryComplexity.count ===
'number') and not equal to -1, then compare includeCount > count (and similarly
for depth) so 0 is enforced correctly.

}

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