-
-
Notifications
You must be signed in to change notification settings - Fork 4.8k
feat: Max depth and max fields protection system #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
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,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`, | ||
| ); | ||
| } | ||
| }, | ||
| }), | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
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. Fix the maxGraphQLQueryComplexity fields description The new help text says the - '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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| maxLimit: { | ||||||||||||||||||||||||||
| env: 'PARSE_SERVER_MAX_LIMIT', | ||||||||||||||||||||||||||
| help: 'Max value for limit option on queries, defaults to unlimited', | ||||||||||||||||||||||||||
|
|
@@ -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', | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
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,10 @@ type RequestKeywordDenylist = { | |
| key: string | any, | ||
| value: any, | ||
| }; | ||
| type QueryComplexityOptions = { | ||
| depth: number, | ||
| fields: number, | ||
| }; | ||
|
|
||
| export interface ParseServerOptions { | ||
| /* Your Parse Application ID | ||
|
|
@@ -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
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. Clarify the meaning of The public option comment repeats that 🧰 Tools🪛 Biome (2.1.2)[error] 362-362: 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 |
|---|---|---|
|
|
@@ -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': | ||
|
|
@@ -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; | ||
| } | ||
|
|
@@ -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
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. Count requested include paths, not internal expansion nodes
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| // A convenient method to perform all the steps of processing a query | ||
|
|
||
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.
Pass the resolved operation into complexity calculation
With multi-operation documents
getOperationAST(document)returnsnullunless we supply the selectedoperationName. 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, setoperationNameto the expensive one, and bypass the limiter entirely. Please feed the resolved operation into the calculation—e.g. passrequestContext.request.operationName(or reuserequestContext.operation) tocalculateQueryComplexityand update that helper to respect it—so multi-operation requests cannot skip validation.🤖 Prompt for AI Agents