-
-
Notifications
You must be signed in to change notification settings - Fork 4.8k
feat: Add Parse Server options maxIncludeQueryComplexity, maxGraphQLQueryComplexity to limit query complexity for performance protection
#9920
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: alpha
Are you sure you want to change the base?
Changes from all commits
278808d
cfd3189
9343bc0
18ff763
6d59d8f
5d405e9
1b6d5c7
5cc723f
251e9b8
025ea8a
672dda1
996bdc2
4f7e157
7bfd8c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| 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); | ||
| }, | ||
| }), | ||
| }; | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep 🧰 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 |
||
| } | ||
|
|
||
| export interface RateLimitOptions { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
mtrezza marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ); | ||
| } | ||
|
Comment on lines
210
to
+223
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Decide intended Also applies to: 252-266 🤖 Prompt for AI Agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: Length of output: 3443 🏁 Script executed: Length of output: 5039 🏁 Script executed: Length of output: 5915 @Moumouls The tests need to be updated to match your intended behavior. Currently, these two test cases expect Lines 1386-1411: "should allow includeAll when depth is -1"
Lines 1413-1438: "should allow includeAll when count is -1"
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 You could also consider updating the documentation in This would make it clear that partial 🧠 Learnings used |
||
| this.includeAll = true; | ||
| break; | ||
| case 'explain': | ||
|
|
@@ -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; | ||
| } | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: - 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 |
||
| } | ||
|
|
||
| // A convenient method to perform all the steps of processing a query | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.