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..b5277e63df 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,11 +438,21 @@ function executeOperation( rootType, operation.selectionSet, ); + + checkAliasCount(exeContext, rootFields); + const path = undefined; 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, @@ -431,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, + ); } } @@ -449,6 +486,7 @@ function executeFieldsSerially( sourceValue: unknown, path: Path | undefined, fields: Map>, + depth: number, ): PromiseOrValue> { return promiseReduce( fields.entries(), @@ -460,6 +498,7 @@ function executeFieldsSerially( sourceValue, fieldNodes, fieldPath, + depth, ); if (result === undefined) { return results; @@ -487,6 +526,7 @@ function executeFields( sourceValue: unknown, path: Path | undefined, fields: Map>, + depth: number, ): PromiseOrValue> { const results = Object.create(null); let containsPromise = false; @@ -500,6 +540,7 @@ function executeFields( sourceValue, fieldNodes, fieldPath, + depth, ); if (result !== undefined) { @@ -530,6 +571,32 @@ 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 }, + ); + } + } +} + /** * Implements the "Executing fields" section of the spec * In particular, this function figures out the value that the field returns by @@ -542,7 +609,16 @@ function executeField( source: unknown, fieldNodes: ReadonlyArray, path: Path, + depth: number, ): PromiseOrValue { + // Check depth limit before resolving the field. + 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]); if (!fieldDef) { return; @@ -580,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( @@ -590,6 +674,7 @@ function executeField( info, path, result, + depth, ); } @@ -680,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) { @@ -696,6 +782,7 @@ function completeValue( info, path, result, + depth, ); if (completed === null) { throw new Error( @@ -719,6 +806,7 @@ function completeValue( info, path, result, + depth, ); } @@ -738,6 +826,7 @@ function completeValue( info, path, result, + depth, ); } @@ -750,6 +839,7 @@ function completeValue( info, path, result, + depth, ); } /* c8 ignore next 6 */ @@ -771,6 +861,7 @@ function completeListValue( info: GraphQLResolveInfo, path: Path, result: unknown, + depth: number, ): PromiseOrValue> { if (!isIterableObject(result)) { throw new GraphQLError( @@ -797,6 +888,7 @@ function completeListValue( info, itemPath, resolved, + depth, ), ); } else { @@ -807,6 +899,7 @@ function completeListValue( info, itemPath, item, + depth, ); } @@ -862,6 +955,7 @@ function completeAbstractValue( info: GraphQLResolveInfo, path: Path, result: unknown, + depth: number, ): PromiseOrValue> { const resolveTypeFn = returnType.resolveType ?? exeContext.typeResolver; const contextValue = exeContext.contextValue; @@ -883,6 +977,7 @@ function completeAbstractValue( info, path, result, + depth, ), ); } @@ -901,6 +996,7 @@ function completeAbstractValue( info, path, result, + depth, ); } @@ -969,10 +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. @@ -990,6 +1091,7 @@ function completeObjectValue( result, path, subFieldNodes, + subDepth, ); }); } @@ -999,7 +1101,14 @@ function completeObjectValue( } } - return executeFields(exeContext, returnType, result, path, subFieldNodes); + return executeFields( + exeContext, + returnType, + result, + path, + subFieldNodes, + subDepth, + ); } function invalidReturnTypeError(