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.