Skip to content

Commit 46706d4

Browse files
authored
Add maxQueryNodes limit (#98)
1 parent 81803a8 commit 46706d4

File tree

3 files changed

+109
-4
lines changed

3 files changed

+109
-4
lines changed

README.md

+9-2
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131

3232
const rule = createComplexityRule({
3333
// The maximum allowed query complexity, queries above this threshold will be rejected
34-
maximumComplexity: 1000,
34+
maximumComplexity: 1_000,
3535

3636
// The query variables. This is needed because the variables are not available
3737
// in the visitor of the graphql-js library
@@ -40,9 +40,16 @@ const rule = createComplexityRule({
4040
// The context object for the request (optional)
4141
context: {}
4242

43-
// specify operation name only when pass multi-operation documents
43+
// Specify operation name when evaluating multi-operation documents
4444
operationName?: string,
4545

46+
// The maximum number of query nodes to evaluate (fields, fragments, composite types).
47+
// If a query contains more than the specified number of nodes, the complexity rule will
48+
// throw an error, regardless of the complexity of the query.
49+
//
50+
// Default: 10_000
51+
maxQueryNodes?: 10_000,
52+
4653
// Optional callback function to retrieve the determined query complexity
4754
// Will be invoked whether the query is rejected or not
4855
// This can be used for logging or to implement rate limiting

src/QueryComplexity.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export interface QueryComplexityOptions {
8585

8686
// Pass request context to the estimators via estimationContext
8787
context?: Record<string, any>;
88+
89+
// The maximum number of nodes to evaluate. If this is set, the query will be
90+
// rejected if it exceeds this number. (Includes fields, fragments, inline fragments, etc.)
91+
// Defaults to 10_000.
92+
maxQueryNodes?: number;
8893
}
8994

9095
function queryComplexityMessage(max: number, actual: number): string {
@@ -101,6 +106,7 @@ export function getComplexity(options: {
101106
variables?: Record<string, any>;
102107
operationName?: string;
103108
context?: Record<string, any>;
109+
maxQueryNodes?: number;
104110
}): number {
105111
const typeInfo = new TypeInfo(options.schema);
106112

@@ -118,6 +124,7 @@ export function getComplexity(options: {
118124
variables: options.variables,
119125
operationName: options.operationName,
120126
context: options.context,
127+
maxQueryNodes: options.maxQueryNodes,
121128
});
122129

123130
visit(options.query, visitWithTypeInfo(typeInfo, visitor));
@@ -140,6 +147,8 @@ export default class QueryComplexity {
140147
skipDirectiveDef: GraphQLDirective;
141148
variableValues: Record<string, any>;
142149
requestContext?: Record<string, any>;
150+
evaluatedNodes: number;
151+
maxQueryNodes: number;
143152

144153
constructor(context: ValidationContext, options: QueryComplexityOptions) {
145154
if (
@@ -154,7 +163,8 @@ export default class QueryComplexity {
154163
this.context = context;
155164
this.complexity = 0;
156165
this.options = options;
157-
166+
this.evaluatedNodes = 0;
167+
this.maxQueryNodes = options.maxQueryNodes ?? 10_000;
158168
this.includeDirectiveDef = this.context.getSchema().getDirective('include');
159169
this.skipDirectiveDef = this.context.getSchema().getDirective('skip');
160170
this.estimators = options.estimators;
@@ -274,7 +284,12 @@ export default class QueryComplexity {
274284
complexities: ComplexityMap,
275285
childNode: FieldNode | FragmentSpreadNode | InlineFragmentNode
276286
): ComplexityMap => {
277-
// let nodeComplexity = 0;
287+
this.evaluatedNodes++;
288+
if (this.evaluatedNodes >= this.maxQueryNodes) {
289+
throw new GraphQLError(
290+
'Query exceeds the maximum allowed number of nodes.'
291+
);
292+
}
278293
let innerComplexities = complexities;
279294

280295
let includeNode = true;

src/__tests__/QueryComplexity-test.ts

+83
Original file line numberDiff line numberDiff line change
@@ -939,4 +939,87 @@ describe('QueryComplexity analysis', () => {
939939

940940
expect(errors).to.have.length(0);
941941
});
942+
943+
it('should reject queries that exceed the maximum number of fragment nodes', () => {
944+
const query = parse(`
945+
query {
946+
...F
947+
...F
948+
}
949+
fragment F on Query {
950+
scalar
951+
}
952+
`);
953+
954+
expect(() =>
955+
getComplexity({
956+
estimators: [simpleEstimator({ defaultComplexity: 1 })],
957+
schema,
958+
query,
959+
maxQueryNodes: 1,
960+
variables: {},
961+
})
962+
).to.throw('Query exceeds the maximum allowed number of nodes.');
963+
});
964+
965+
it('should reject queries that exceed the maximum number of field nodes', () => {
966+
const query = parse(`
967+
query {
968+
scalar
969+
scalar1: scalar
970+
scalar2: scalar
971+
scalar3: scalar
972+
scalar4: scalar
973+
scalar5: scalar
974+
scalar6: scalar
975+
scalar7: scalar
976+
}
977+
`);
978+
979+
expect(() =>
980+
getComplexity({
981+
estimators: [simpleEstimator({ defaultComplexity: 1 })],
982+
schema,
983+
query,
984+
maxQueryNodes: 1,
985+
variables: {},
986+
})
987+
).to.throw('Query exceeds the maximum allowed number of nodes.');
988+
});
989+
990+
it('should limit the number of query nodes to 10_000 by default', () => {
991+
const failingQuery = parse(`
992+
query {
993+
${Array.from({ length: 10_000 }, (_, i) => `scalar${i}: scalar`).join(
994+
'\n'
995+
)}
996+
}
997+
`);
998+
999+
expect(() =>
1000+
getComplexity({
1001+
estimators: [simpleEstimator({ defaultComplexity: 1 })],
1002+
schema,
1003+
query: failingQuery,
1004+
variables: {},
1005+
})
1006+
).to.throw('Query exceeds the maximum allowed number of nodes.');
1007+
1008+
const passingQuery = parse(`
1009+
query {
1010+
${Array.from({ length: 9999 }, (_, i) => `scalar${i}: scalar`).join(
1011+
'\n'
1012+
)}
1013+
}
1014+
`);
1015+
1016+
expect(() =>
1017+
getComplexity({
1018+
estimators: [simpleEstimator({ defaultComplexity: 1 })],
1019+
schema,
1020+
query: passingQuery,
1021+
variables: {},
1022+
})
1023+
).not.to.throw();
1024+
});
9421025
});

0 commit comments

Comments
 (0)