Skip to content

Commit 89e3f84

Browse files
clenfestgoto-bus-stopsachindshinde
authored
Add optional validation to reject operations with many recursive selections (#8054)
Add optional validation to reject operations with many recursive selections Adds a new graphql-js validation rule to reject operations that recursively request selections above a specified maximum, which is disabled by default. Use configuration option `maxRecursiveSelections=true` to enable with a maximum of 10,000,000, or `maxRecursiveSelections=<number>` for a custom maximum. Enabling this validation can help avoid performance issues with configured validation rules or plugins. --------- Co-authored-by: Renée <[email protected]> Co-authored-by: Sachin D. Shinde <[email protected]>
1 parent ba3d8bf commit 89e3f84

File tree

9 files changed

+371
-29
lines changed

9 files changed

+371
-29
lines changed

.changeset/cuddly-tables-turn.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@apollo/server': minor
3+
---
4+
5+
Adds a new graphql-js validation rule to reject operations that recursively request selections above a specified maximum, which is disabled by default. Use configuration option `maxRecursiveSelections=true` to enable with a maximum of 10,000,000, or `maxRecursiveSelections=<number>` for a custom maximum. Enabling this validation can help avoid performance issues with configured validation rules or plugins.

packages/server/src/ApolloServer.ts

+31-28
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {
2121
type GraphQLSchema,
2222
type ParseOptions,
2323
type TypedQueryDocumentNode,
24-
type ValidationContext,
2524
type ValidationRule,
2625
} from 'graphql';
2726
import loglevel from 'loglevel';
@@ -33,10 +32,7 @@ import {
3332
ensureGraphQLError,
3433
normalizeAndFormatErrors,
3534
} from './errorNormalize.js';
36-
import {
37-
ApolloServerErrorCode,
38-
ApolloServerValidationErrorCode,
39-
} from './errors/index.js';
35+
import { ApolloServerErrorCode } from './errors/index.js';
4036
import type { ApolloServerOptionsWithStaticSchema } from './externalTypes/constructor.js';
4137
import type {
4238
ExecuteOperationOptions,
@@ -74,25 +70,11 @@ import { UnreachableCaseError } from './utils/UnreachableCaseError.js';
7470
import { computeCoreSchemaHash } from './utils/computeCoreSchemaHash.js';
7571
import { isDefined } from './utils/isDefined.js';
7672
import { SchemaManager } from './utils/schemaManager.js';
77-
78-
const NoIntrospection: ValidationRule = (context: ValidationContext) => ({
79-
Field(node) {
80-
if (node.name.value === '__schema' || node.name.value === '__type') {
81-
context.reportError(
82-
new GraphQLError(
83-
'GraphQL introspection is not allowed by Apollo Server, but the query contained __schema or __type. To enable introspection, pass introspection: true to ApolloServer in production',
84-
{
85-
nodes: [node],
86-
extensions: {
87-
validationErrorCode:
88-
ApolloServerValidationErrorCode.INTROSPECTION_DISABLED,
89-
},
90-
},
91-
),
92-
);
93-
}
94-
},
95-
});
73+
import {
74+
NoIntrospection,
75+
createMaxRecursiveSelectionsRule,
76+
DEFAULT_MAX_RECURSIVE_SELECTIONS,
77+
} from './validationRules/index.js';
9678

9779
export type SchemaDerivedData = {
9880
schema: GraphQLSchema;
@@ -175,6 +157,7 @@ export interface ApolloServerInternals<TContext extends BaseContext> {
175157

176158
rootValue?: ((parsedQuery: DocumentNode) => unknown) | unknown;
177159
validationRules: Array<ValidationRule>;
160+
laterValidationRules?: Array<ValidationRule>;
178161
hideSchemaDetailsFromClientErrors: boolean;
179162
fieldResolver?: GraphQLFieldResolver<any, TContext>;
180163
// TODO(AS5): remove OR warn + ignore with this option set, ignore option and
@@ -292,15 +275,35 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
292275
? new InMemoryLRUCache()
293276
: config.cache;
294277

278+
// Check whether the recursive selections limit has been enabled (off by
279+
// default), or whether a custom limit has been specified.
280+
const maxRecursiveSelectionsRule =
281+
config.maxRecursiveSelections === true
282+
? [createMaxRecursiveSelectionsRule(DEFAULT_MAX_RECURSIVE_SELECTIONS)]
283+
: typeof config.maxRecursiveSelections === 'number'
284+
? [createMaxRecursiveSelectionsRule(config.maxRecursiveSelections)]
285+
: [];
286+
287+
// If the recursive selections rule has been enabled, then run configured
288+
// validations in a later validate() pass.
289+
const validationRules = [
290+
...(introspectionEnabled ? [] : [NoIntrospection]),
291+
...maxRecursiveSelectionsRule,
292+
];
293+
let laterValidationRules;
294+
if (maxRecursiveSelectionsRule.length > 0) {
295+
laterValidationRules = config.validationRules;
296+
} else {
297+
validationRules.push(...(config.validationRules ?? []));
298+
}
299+
295300
// Note that we avoid calling methods on `this` before `this.internals` is assigned
296301
// (thus a bunch of things being static methods above).
297302
this.internals = {
298303
formatError: config.formatError,
299304
rootValue: config.rootValue,
300-
validationRules: [
301-
...(config.validationRules ?? []),
302-
...(introspectionEnabled ? [] : [NoIntrospection]),
303-
],
305+
validationRules,
306+
laterValidationRules,
304307
hideSchemaDetailsFromClientErrors,
305308
dangerouslyDisableValidation:
306309
config.dangerouslyDisableValidation ?? false,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
import {
3+
validate,
4+
buildSchema,
5+
specifiedRules,
6+
parse,
7+
GraphQLError,
8+
} from 'graphql';
9+
import { createMaxRecursiveSelectionsRule } from '../../validationRules/index.js';
10+
11+
describe('selection limit tests', () => {
12+
it('selection limit threshold', () => {
13+
const schema = buildSchema(`
14+
type Query {
15+
user: User
16+
}
17+
18+
type User {
19+
id: ID
20+
name: String
21+
email: String
22+
age: Int
23+
address: String
24+
phone: String
25+
}
26+
`);
27+
28+
const query = `
29+
query {
30+
user {
31+
id
32+
name
33+
...UserDetails
34+
}
35+
}
36+
37+
fragment UserDetails on User {
38+
email
39+
age
40+
...MoreDetails
41+
}
42+
43+
fragment MoreDetails on User {
44+
address
45+
phone
46+
}
47+
`;
48+
49+
const biggerQuery = `
50+
query {
51+
user {
52+
email
53+
age
54+
address
55+
phone
56+
...UserDetails
57+
}
58+
}
59+
60+
fragment UserDetails on User {
61+
id
62+
name
63+
email
64+
age
65+
...MoreDetails
66+
}
67+
68+
fragment MoreDetails on User {
69+
id
70+
name
71+
address
72+
phone
73+
}
74+
`;
75+
76+
const selectionLimitRule = createMaxRecursiveSelectionsRule(10);
77+
78+
// check the one that exceeds first to ensure that field count gets reset in between validations
79+
let errors = validate(schema, parse(biggerQuery), [
80+
...specifiedRules,
81+
selectionLimitRule,
82+
]);
83+
expect(errors).toEqual([
84+
new GraphQLError(
85+
'Anonymous operation recursively requests too many selections.',
86+
),
87+
]);
88+
89+
errors = validate(schema, parse(query), [
90+
...specifiedRules,
91+
selectionLimitRule,
92+
]);
93+
expect(errors).toEqual([]);
94+
});
95+
});

packages/server/src/errors/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export enum ApolloServerErrorCode {
1313

1414
export enum ApolloServerValidationErrorCode {
1515
INTROSPECTION_DISABLED = 'INTROSPECTION_DISABLED',
16+
MAX_RECURSIVE_SELECTIONS_EXCEEDED = 'MAX_RECURSIVE_SELECTIONS_EXCEEDED',
1617
}
1718

1819
/**

packages/server/src/externalTypes/constructor.ts

+1
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ interface ApolloServerOptionsBase<TContext extends BaseContext> {
9393
value: FormattedExecutionResult,
9494
) => string | Promise<string>;
9595
introspection?: boolean;
96+
maxRecursiveSelections?: boolean | number;
9697
hideSchemaDetailsFromClientErrors?: boolean;
9798
plugins?: ApolloServerPlugin<TContext>[];
9899
persistedQueries?: PersistedQueryOptions | false;

packages/server/src/requestPipeline.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -243,11 +243,18 @@ export async function processGraphQLRequest<TContext extends BaseContext>(
243243
),
244244
);
245245

246-
const validationErrors = validate(
246+
let validationErrors = validate(
247247
schemaDerivedData.schema,
248248
requestContext.document,
249249
[...specifiedRules, ...internals.validationRules],
250250
);
251+
if (validationErrors.length === 0 && internals.laterValidationRules) {
252+
validationErrors = validate(
253+
schemaDerivedData.schema,
254+
requestContext.document,
255+
internals.laterValidationRules,
256+
);
257+
}
251258

252259
if (validationErrors.length === 0) {
253260
await validationDidEnd();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
GraphQLError,
3+
type ValidationRule,
4+
type ValidationContext,
5+
} from 'graphql';
6+
import { ApolloServerValidationErrorCode } from '../errors/index.js';
7+
8+
export const NoIntrospection: ValidationRule = (
9+
context: ValidationContext,
10+
) => ({
11+
Field(node) {
12+
if (node.name.value === '__schema' || node.name.value === '__type') {
13+
context.reportError(
14+
new GraphQLError(
15+
'GraphQL introspection is not allowed by Apollo Server, but the query contained __schema or __type. To enable introspection, pass introspection: true to ApolloServer in production',
16+
{
17+
nodes: [node],
18+
extensions: {
19+
validationErrorCode:
20+
ApolloServerValidationErrorCode.INTROSPECTION_DISABLED,
21+
},
22+
},
23+
),
24+
);
25+
}
26+
},
27+
});

0 commit comments

Comments
 (0)