-
-
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 8 commits
278808d
cfd3189
9343bc0
18ff763
6d59d8f
5d405e9
1b6d5c7
5cc723f
251e9b8
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,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); | ||
| }, | ||
| }), | ||
| }; | ||
| } |
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 |
|---|---|---|
|
|
@@ -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' | ||
|
Member
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. Is that a sensible constraint? |
||
| ); | ||
| } | ||
| this.includeAll = true; | ||
| break; | ||
| case 'explain': | ||
|
|
@@ -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; | ||
| } | ||
|
|
@@ -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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.