From 43e0e57bce1380751ca8b2927f32fbbe74076ede Mon Sep 17 00:00:00 2001 From: eddie Date: Tue, 14 Apr 2026 13:34:02 +0800 Subject: [PATCH 1/2] Add optional maxDepth and maxAliases execution options Add opt-in depth and alias limits to the execution engine to mitigate denial-of-service attacks via deeply nested queries and alias bombing. - maxDepth: limits the field nesting depth during execution. When a field exceeds the configured depth, a GraphQLError is raised and the parent field resolves to null (standard error handling). - maxAliases: limits the number of response keys (including aliases) per selection set. When exceeded, a GraphQLError is raised before the selection set is executed. Both options are undefined by default, preserving full backwards compatibility. They are passed via ExecutionArgs.options alongside the existing maxCoercionErrors option. Fixes #4662 --- .../__tests__/depth-and-alias-limits-test.ts | 400 ++++++++++++++++++ src/execution/execute.ts | 77 ++++ 2 files changed, 477 insertions(+) create mode 100644 src/execution/__tests__/depth-and-alias-limits-test.ts diff --git a/src/execution/__tests__/depth-and-alias-limits-test.ts b/src/execution/__tests__/depth-and-alias-limits-test.ts new file mode 100644 index 0000000000..796bd5decb --- /dev/null +++ b/src/execution/__tests__/depth-and-alias-limits-test.ts @@ -0,0 +1,400 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON'; + +import { parse } from '../../language/parser'; + +import { + GraphQLList, + GraphQLObjectType, +} from '../../type/definition'; +import { GraphQLString } from '../../type/scalars'; +import { GraphQLSchema } from '../../type/schema'; + +import { executeSync } from '../execute'; + +// A recursive type that allows arbitrary nesting: { nest { nest { ... } } } +const NestType: GraphQLObjectType = new GraphQLObjectType({ + name: 'Nest', + fields: () => ({ + value: { type: GraphQLString }, + nest: { type: NestType }, + items: { type: new GraphQLList(NestType) }, + }), +}); + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + nest: { type: NestType }, + value: { type: GraphQLString }, + }, + }), +}); + +function nestData(depth: number): unknown { + if (depth <= 0) { + return { value: 'leaf', nest: null, items: [] }; + } + return { + value: `depth-${depth}`, + nest: () => nestData(depth - 1), + items: () => [nestData(depth - 1)], + }; +} + +describe('Execute: maxDepth option', () => { + it('allows queries within the depth limit', () => { + const document = parse(` + { + nest { + nest { + value + } + } + } + `); + + const result = executeSync({ + schema, + document, + rootValue: nestData(3), + options: { maxDepth: 4 }, + }); + + expect(result.errors).to.equal(undefined); + expect(result.data).to.deep.equal({ + nest: { nest: { value: 'depth-1' } }, + }); + }); + + it('returns error when query exceeds depth limit', () => { + const document = parse(` + { + nest { + nest { + nest { + value + } + } + } + } + `); + + const result = executeSync({ + schema, + document, + rootValue: nestData(5), + options: { maxDepth: 3 }, + }); + + // The error propagates up to the parent field (nest at depth 3) + // which gets null'd out because the child field (value) at depth 4 + // exceeds the limit. + expectJSON(result).toDeepEqual({ + data: { nest: { nest: { nest: null } } }, + errors: [ + { + message: + 'Query depth limit of 3 exceeded, found depth: 4.', + locations: [{ line: 6, column: 15 }], + path: ['nest', 'nest', 'nest'], + }, + ], + }); + }); + + it('does not apply depth limit when option is not set', () => { + const document = parse(` + { + nest { + nest { + nest { + nest { + value + } + } + } + } + } + `); + + const result = executeSync({ + schema, + document, + rootValue: nestData(10), + }); + + expect(result.errors).to.equal(undefined); + expect(result.data).to.deep.equal({ + nest: { nest: { nest: { nest: { value: 'depth-6' } } } }, + }); + }); + + it('depth limit of 1 allows only root fields', () => { + const document = parse(` + { + value + } + `); + + const result = executeSync({ + schema, + document, + rootValue: { value: 'root' }, + options: { maxDepth: 1 }, + }); + + expect(result.errors).to.equal(undefined); + expect(result.data).to.deep.equal({ value: 'root' }); + }); + + it('depth limit of 1 rejects nested fields', () => { + const document = parse(` + { + nest { + value + } + } + `); + + const result = executeSync({ + schema, + document, + rootValue: nestData(3), + options: { maxDepth: 1 }, + }); + + // The error at depth 2 (value) propagates up and null's the parent (nest) + expectJSON(result).toDeepEqual({ + data: { nest: null }, + errors: [ + { + message: + 'Query depth limit of 1 exceeded, found depth: 2.', + locations: [{ line: 4, column: 11 }], + path: ['nest'], + }, + ], + }); + }); + + it('does not count list indices as depth', () => { + const document = parse(` + { + nest { + items { + value + } + } + } + `); + + const result = executeSync({ + schema, + document, + rootValue: { + nest: { + items: [{ value: 'a' }, { value: 'b' }], + }, + }, + // depth: query(1) -> nest(2) -> items(3) -> [index] -> value(4) + // list indices should NOT count, so value is at depth 4 + options: { maxDepth: 4 }, + }); + + expect(result.errors).to.equal(undefined); + expect(result.data).to.deep.equal({ + nest: { items: [{ value: 'a' }, { value: 'b' }] }, + }); + }); +}); + +describe('Execute: maxAliases option', () => { + it('allows queries within the alias limit', () => { + const document = parse(` + { + a: value + b: value + c: value + } + `); + + const result = executeSync({ + schema, + document, + rootValue: { value: 'ok' }, + options: { maxAliases: 3 }, + }); + + expect(result.errors).to.equal(undefined); + expect(result.data).to.deep.equal({ a: 'ok', b: 'ok', c: 'ok' }); + }); + + it('returns error when root aliases exceed limit', () => { + const document = parse(` + { + a: value + b: value + c: value + d: value + } + `); + + const result = executeSync({ + schema, + document, + rootValue: { value: 'ok' }, + options: { maxAliases: 3 }, + }); + + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: + 'Aliases limit of 3 exceeded, found 4 aliases.', + locations: [ + { line: 3, column: 9 }, + { line: 4, column: 9 }, + { line: 5, column: 9 }, + { line: 6, column: 9 }, + ], + }, + ], + }); + }); + + it('returns error when nested aliases exceed limit', () => { + const document = parse(` + { + nest { + a: value + b: value + c: value + d: value + } + } + `); + + const result = executeSync({ + schema, + document, + rootValue: nestData(3), + options: { maxAliases: 3 }, + }); + + expectJSON(result).toDeepEqual({ + data: { nest: null }, + errors: [ + { + message: + 'Aliases limit of 3 exceeded, found 4 aliases.', + locations: [ + { line: 4, column: 11 }, + { line: 5, column: 11 }, + { line: 6, column: 11 }, + { line: 7, column: 11 }, + ], + path: ['nest'], + }, + ], + }); + }); + + it('does not apply alias limit when option is not set', () => { + const document = parse(` + { + a: value + b: value + c: value + d: value + e: value + } + `); + + const result = executeSync({ + schema, + document, + rootValue: { value: 'ok' }, + }); + + expect(result.errors).to.equal(undefined); + expect(result.data).to.deep.equal({ + a: 'ok', + b: 'ok', + c: 'ok', + d: 'ok', + e: 'ok', + }); + }); + + it('counts non-aliased fields toward the limit', () => { + // Even without explicit aliases, each unique response key counts. + const document = parse(` + { + value + nest { + value + } + } + `); + + const result = executeSync({ + schema, + document, + rootValue: nestData(3), + options: { maxAliases: 2 }, + }); + + expect(result.errors).to.equal(undefined); + expect(result.data).to.deep.equal({ + value: 'depth-3', + nest: { value: 'depth-2' }, + }); + }); + + it('both limits can be used together', () => { + const document = parse(` + { + a: value + b: value + nest { + nest { + nest { + value + } + } + } + } + `); + + const result = executeSync({ + schema, + document, + rootValue: nestData(5), + options: { maxDepth: 3, maxAliases: 5 }, + }); + + // Alias check passes (3 root keys: a, b, nest), but depth check fails + // at nest.nest.nest.value (depth 4 > maxDepth 3). The error propagates + // up to the parent field (nest at depth 3) which gets null'd out. + expectJSON(result).toDeepEqual({ + data: { + a: 'depth-5', + b: 'depth-5', + nest: { nest: { nest: null } }, + }, + errors: [ + { + message: + 'Query depth limit of 3 exceeded, found depth: 4.', + locations: [{ line: 8, column: 15 }], + path: ['nest', 'nest', 'nest'], + }, + ], + }); + }); +}); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 1e5ec12c9a..df36fb83e7 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -115,6 +115,8 @@ export interface ExecutionContext { typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; collectedErrors: CollectedErrors; + maxDepth: number | undefined; + maxAliases: number | undefined; } /** @@ -195,6 +197,21 @@ export interface ExecutionArgs { options?: { /** Set the maximum number of errors allowed for coercing (defaults to 50). */ maxCoercionErrors?: number; + /** + * Set the maximum allowed depth for field resolution. + * Depth is counted as the number of nested field selections from the root. + * When exceeded, a GraphQLError is thrown for the offending field. + * No limit is applied when undefined (the default). + */ + maxDepth?: number; + /** + * Set the maximum number of aliases allowed in any single selection set. + * This helps prevent alias-bombing denial-of-service attacks where many + * aliases for the same field bypass depth-based protections. + * When exceeded, a GraphQLError is thrown before executing the selection set. + * No limit is applied when undefined (the default). + */ + maxAliases?: number; }; } @@ -393,6 +410,8 @@ export function buildExecutionContext( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, collectedErrors: new CollectedErrors(), + maxDepth: options?.maxDepth, + maxAliases: options?.maxAliases, }; } @@ -419,6 +438,9 @@ function executeOperation( rootType, operation.selectionSet, ); + + checkAliasCount(exeContext, rootFields); + const path = undefined; switch (operation.operation) { @@ -530,6 +552,48 @@ function executeFields( return promiseForObject(results); } +/** + * Checks whether the number of response keys (including aliases) in a + * selection set exceeds the configured limit. Throws a GraphQLError when + * the limit is exceeded. + */ +function checkAliasCount( + exeContext: ExecutionContext, + fields: Map>, +): void { + if (exeContext.maxAliases !== undefined) { + const aliasCount = fields.size; + if (aliasCount > exeContext.maxAliases) { + // Collect nodes for the error location from the first field node of + // each response key beyond the limit. + const nodes: Array = []; + for (const [, fieldNodes] of fields) { + nodes.push(fieldNodes[0]); + } + throw new GraphQLError( + `Aliases limit of ${exeContext.maxAliases} exceeded, found ${aliasCount} aliases.`, + { nodes }, + ); + } + } +} + +/** + * Counts the field depth represented by a Path. Only string keys are counted + * (numeric keys represent list indices and should not increase the depth). + */ +function pathDepth(path: Path | undefined): number { + let depth = 0; + let current = path; + while (current !== undefined) { + if (typeof current.key === 'string') { + depth++; + } + current = current.prev; + } + return depth; +} + /** * Implements the "Executing fields" section of the spec * In particular, this function figures out the value that the field returns by @@ -543,6 +607,17 @@ function executeField( fieldNodes: ReadonlyArray, path: Path, ): PromiseOrValue { + // Check depth limit before resolving the field. + if (exeContext.maxDepth !== undefined) { + const depth = pathDepth(path); + if (depth > exeContext.maxDepth) { + throw new GraphQLError( + `Query depth limit of ${exeContext.maxDepth} exceeded, found depth: ${depth}.`, + { nodes: fieldNodes }, + ); + } + } + const fieldDef = getFieldDef(exeContext.schema, parentType, fieldNodes[0]); if (!fieldDef) { return; @@ -973,6 +1048,8 @@ function completeObjectValue( // Collect sub-fields to execute to complete this value. const subFieldNodes = collectSubfields(exeContext, returnType, fieldNodes); + checkAliasCount(exeContext, subFieldNodes); + // If there is an isTypeOf predicate function, call it with the // current result. If isTypeOf returns false, then raise an error rather // than continuing execution. From f9d67dcdf0601207b3b7eabee422377cf26e826f Mon Sep 17 00:00:00 2001 From: Ran Date: Fri, 17 Apr 2026 06:06:14 +0800 Subject: [PATCH 2/2] Pass depth through execution call chain instead of recomputing Addresses review feedback: rather than walking the Path linked list at every executeField invocation, thread the current field depth as an explicit parameter through executeFields/executeField/completeValue and its helpers. completeObjectValue increments depth when descending into sub-selections; list items and abstract-type resolution keep the same depth as their parent field. This changes the depth check from O(depth) per field resolution to O(1), while preserving identical semantics (list indices still do not count toward depth). --- src/execution/execute.ts | 88 +++++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/src/execution/execute.ts b/src/execution/execute.ts index df36fb83e7..b5277e63df 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -445,7 +445,14 @@ function executeOperation( switch (operation.operation) { case OperationTypeNode.QUERY: - return executeFields(exeContext, rootType, rootValue, path, rootFields); + return executeFields( + exeContext, + rootType, + rootValue, + path, + rootFields, + 1, + ); case OperationTypeNode.MUTATION: return executeFieldsSerially( exeContext, @@ -453,11 +460,19 @@ function executeOperation( rootValue, path, rootFields, + 1, ); case OperationTypeNode.SUBSCRIPTION: // TODO: deprecate `subscribe` and move all logic here // Temporary solution until we finish merging execute and subscribe together - return executeFields(exeContext, rootType, rootValue, path, rootFields); + return executeFields( + exeContext, + rootType, + rootValue, + path, + rootFields, + 1, + ); } } @@ -471,6 +486,7 @@ function executeFieldsSerially( sourceValue: unknown, path: Path | undefined, fields: Map>, + depth: number, ): PromiseOrValue> { return promiseReduce( fields.entries(), @@ -482,6 +498,7 @@ function executeFieldsSerially( sourceValue, fieldNodes, fieldPath, + depth, ); if (result === undefined) { return results; @@ -509,6 +526,7 @@ function executeFields( sourceValue: unknown, path: Path | undefined, fields: Map>, + depth: number, ): PromiseOrValue> { const results = Object.create(null); let containsPromise = false; @@ -522,6 +540,7 @@ function executeFields( sourceValue, fieldNodes, fieldPath, + depth, ); if (result !== undefined) { @@ -578,22 +597,6 @@ function checkAliasCount( } } -/** - * Counts the field depth represented by a Path. Only string keys are counted - * (numeric keys represent list indices and should not increase the depth). - */ -function pathDepth(path: Path | undefined): number { - let depth = 0; - let current = path; - while (current !== undefined) { - if (typeof current.key === 'string') { - depth++; - } - current = current.prev; - } - return depth; -} - /** * Implements the "Executing fields" section of the spec * In particular, this function figures out the value that the field returns by @@ -606,16 +609,14 @@ function executeField( source: unknown, fieldNodes: ReadonlyArray, path: Path, + depth: number, ): PromiseOrValue { // Check depth limit before resolving the field. - if (exeContext.maxDepth !== undefined) { - const depth = pathDepth(path); - if (depth > exeContext.maxDepth) { - throw new GraphQLError( - `Query depth limit of ${exeContext.maxDepth} exceeded, found depth: ${depth}.`, - { nodes: fieldNodes }, - ); - } + if (exeContext.maxDepth !== undefined && depth > exeContext.maxDepth) { + throw new GraphQLError( + `Query depth limit of ${exeContext.maxDepth} exceeded, found depth: ${depth}.`, + { nodes: fieldNodes }, + ); } const fieldDef = getFieldDef(exeContext.schema, parentType, fieldNodes[0]); @@ -655,7 +656,15 @@ function executeField( let completed; if (isPromise(result)) { completed = result.then((resolved) => - completeValue(exeContext, returnType, fieldNodes, info, path, resolved), + completeValue( + exeContext, + returnType, + fieldNodes, + info, + path, + resolved, + depth, + ), ); } else { completed = completeValue( @@ -665,6 +674,7 @@ function executeField( info, path, result, + depth, ); } @@ -755,6 +765,7 @@ function completeValue( info: GraphQLResolveInfo, path: Path, result: unknown, + depth: number, ): PromiseOrValue { // If result is an Error, throw a located error. if (result instanceof Error) { @@ -771,6 +782,7 @@ function completeValue( info, path, result, + depth, ); if (completed === null) { throw new Error( @@ -794,6 +806,7 @@ function completeValue( info, path, result, + depth, ); } @@ -813,6 +826,7 @@ function completeValue( info, path, result, + depth, ); } @@ -825,6 +839,7 @@ function completeValue( info, path, result, + depth, ); } /* c8 ignore next 6 */ @@ -846,6 +861,7 @@ function completeListValue( info: GraphQLResolveInfo, path: Path, result: unknown, + depth: number, ): PromiseOrValue> { if (!isIterableObject(result)) { throw new GraphQLError( @@ -872,6 +888,7 @@ function completeListValue( info, itemPath, resolved, + depth, ), ); } else { @@ -882,6 +899,7 @@ function completeListValue( info, itemPath, item, + depth, ); } @@ -937,6 +955,7 @@ function completeAbstractValue( info: GraphQLResolveInfo, path: Path, result: unknown, + depth: number, ): PromiseOrValue> { const resolveTypeFn = returnType.resolveType ?? exeContext.typeResolver; const contextValue = exeContext.contextValue; @@ -958,6 +977,7 @@ function completeAbstractValue( info, path, result, + depth, ), ); } @@ -976,6 +996,7 @@ function completeAbstractValue( info, path, result, + depth, ); } @@ -1044,12 +1065,15 @@ function completeObjectValue( info: GraphQLResolveInfo, path: Path, result: unknown, + depth: number, ): PromiseOrValue> { // Collect sub-fields to execute to complete this value. const subFieldNodes = collectSubfields(exeContext, returnType, fieldNodes); checkAliasCount(exeContext, subFieldNodes); + const subDepth = depth + 1; + // If there is an isTypeOf predicate function, call it with the // current result. If isTypeOf returns false, then raise an error rather // than continuing execution. @@ -1067,6 +1091,7 @@ function completeObjectValue( result, path, subFieldNodes, + subDepth, ); }); } @@ -1076,7 +1101,14 @@ function completeObjectValue( } } - return executeFields(exeContext, returnType, result, path, subFieldNodes); + return executeFields( + exeContext, + returnType, + result, + path, + subFieldNodes, + subDepth, + ); } function invalidReturnTypeError(