Repository: apollographql/apollo-server
Pull request #8054 (2025-04-07 13:50:29)

Add optional validation to reject operations with many recursive selections
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.


svc-apollo-docs: ### 🛠️ Docs preview building...

The preview is currently being built.

**Build ID**: aba2997dca480be1f02d401f

Patch:
diff --git a/.changeset/cuddly-tables-turn.md b/.changeset/cuddly-tables-turn.md
new file mode 100644
index 000000000..889c34451
--- /dev/null
+++ b/.changeset/cuddly-tables-turn.md
@@ -0,0 +1,5 @@
+---
+'@apollo/server': minor
+---
+
+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.
diff --git a/packages/server/src/ApolloServer.ts b/packages/server/src/ApolloServer.ts
index 4a9c8d97c..a0ff47ada 100644
--- a/packages/server/src/ApolloServer.ts
+++ b/packages/server/src/ApolloServer.ts
@@ -21,7 +21,6 @@ import {
   type GraphQLSchema,
   type ParseOptions,
   type TypedQueryDocumentNode,
-  type ValidationContext,
   type ValidationRule,
 } from 'graphql';
 import loglevel from 'loglevel';
@@ -33,10 +32,7 @@ import {
   ensureGraphQLError,
   normalizeAndFormatErrors,
 } from './errorNormalize.js';
-import {
-  ApolloServerErrorCode,
-  ApolloServerValidationErrorCode,
-} from './errors/index.js';
+import { ApolloServerErrorCode } from './errors/index.js';
 import type { ApolloServerOptionsWithStaticSchema } from './externalTypes/constructor.js';
 import type {
   ExecuteOperationOptions,
@@ -74,25 +70,11 @@ import { UnreachableCaseError } from './utils/UnreachableCaseError.js';
 import { computeCoreSchemaHash } from './utils/computeCoreSchemaHash.js';
 import { isDefined } from './utils/isDefined.js';
 import { SchemaManager } from './utils/schemaManager.js';
-
-const NoIntrospection: ValidationRule = (context: ValidationContext) => ({
-  Field(node) {
-    if (node.name.value === '__schema' || node.name.value === '__type') {
-      context.reportError(
-        new GraphQLError(
-          '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',
-          {
-            nodes: [node],
-            extensions: {
-              validationErrorCode:
-                ApolloServerValidationErrorCode.INTROSPECTION_DISABLED,
-            },
-          },
-        ),
-      );
-    }
-  },
-});
+import {
+  NoIntrospection,
+  createMaxRecursiveSelectionsRule,
+  DEFAULT_MAX_RECURSIVE_SELECTIONS,
+} from './validationRules/index.js';
 
 export type SchemaDerivedData = {
   schema: GraphQLSchema;
@@ -175,6 +157,7 @@ export interface ApolloServerInternals<TContext extends BaseContext> {
 
   rootValue?: ((parsedQuery: DocumentNode) => unknown) | unknown;
   validationRules: Array<ValidationRule>;
+  laterValidationRules?: Array<ValidationRule>;
   hideSchemaDetailsFromClientErrors: boolean;
   fieldResolver?: GraphQLFieldResolver<any, TContext>;
   // 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> {
         ? new InMemoryLRUCache()
         : config.cache;
 
+    // Check whether the recursive selections limit has been enabled (off by
+    // default), or whether a custom limit has been specified.
+    const maxRecursiveSelectionsRule =
+      config.maxRecursiveSelections === true
+        ? [createMaxRecursiveSelectionsRule(DEFAULT_MAX_RECURSIVE_SELECTIONS)]
+        : typeof config.maxRecursiveSelections === 'number'
+          ? [createMaxRecursiveSelectionsRule(config.maxRecursiveSelections)]
+          : [];
+
+    // If the recursive selections rule has been enabled, then run configured
+    // validations in a later validate() pass.
+    const validationRules = [
+      ...(introspectionEnabled ? [] : [NoIntrospection]),
+      ...maxRecursiveSelectionsRule,
+    ];
+    let laterValidationRules;
+    if (maxRecursiveSelectionsRule.length > 0) {
+      laterValidationRules = config.validationRules;
+    } else {
+      validationRules.push(...(config.validationRules ?? []));
+    }
+
     // Note that we avoid calling methods on `this` before `this.internals` is assigned
     // (thus a bunch of things being static methods above).
     this.internals = {
       formatError: config.formatError,
       rootValue: config.rootValue,
-      validationRules: [
-        ...(config.validationRules ?? []),
-        ...(introspectionEnabled ? [] : [NoIntrospection]),
-      ],
+      validationRules,
+      laterValidationRules,
       hideSchemaDetailsFromClientErrors,
       dangerouslyDisableValidation:
         config.dangerouslyDisableValidation ?? false,
diff --git a/packages/server/src/errors/index.ts b/packages/server/src/errors/index.ts
index cef3c726e..3fecc5e7f 100644
--- a/packages/server/src/errors/index.ts
+++ b/packages/server/src/errors/index.ts
@@ -13,6 +13,7 @@ export enum ApolloServerErrorCode {
 
 export enum ApolloServerValidationErrorCode {
   INTROSPECTION_DISABLED = 'INTROSPECTION_DISABLED',
+  MAX_RECURSIVE_SELECTIONS_EXCEEDED = 'MAX_RECURSIVE_SELECTIONS_EXCEEDED',
 }
 
 /**
diff --git a/packages/server/src/externalTypes/constructor.ts b/packages/server/src/externalTypes/constructor.ts
index 6d6f354f6..48251286e 100644
--- a/packages/server/src/externalTypes/constructor.ts
+++ b/packages/server/src/externalTypes/constructor.ts
@@ -93,6 +93,7 @@ interface ApolloServerOptionsBase<TContext extends BaseContext> {
     value: FormattedExecutionResult,
   ) => string | Promise<string>;
   introspection?: boolean;
+  maxRecursiveSelections?: boolean | number;
   hideSchemaDetailsFromClientErrors?: boolean;
   plugins?: ApolloServerPlugin<TContext>[];
   persistedQueries?: PersistedQueryOptions | false;
diff --git a/packages/server/src/requestPipeline.ts b/packages/server/src/requestPipeline.ts
index b9b4b5542..adfe0683b 100644
--- a/packages/server/src/requestPipeline.ts
+++ b/packages/server/src/requestPipeline.ts
@@ -243,11 +243,18 @@ export async function processGraphQLRequest<TContext extends BaseContext>(
           ),
       );
 
-      const validationErrors = validate(
+      let validationErrors = validate(
         schemaDerivedData.schema,
         requestContext.document,
         [...specifiedRules, ...internals.validationRules],
       );
+      if (validationErrors.length === 0 && internals.laterValidationRules) {
+        validationErrors = validate(
+          schemaDerivedData.schema,
+          requestContext.document,
+          internals.laterValidationRules,
+        );
+      }
 
       if (validationErrors.length === 0) {
         await validationDidEnd();
diff --git a/packages/server/src/validationRules/NoIntrospection.ts b/packages/server/src/validationRules/NoIntrospection.ts
new file mode 100644
index 000000000..0f905787c
--- /dev/null
+++ b/packages/server/src/validationRules/NoIntrospection.ts
@@ -0,0 +1,27 @@
+import {
+  GraphQLError,
+  type ValidationRule,
+  type ValidationContext,
+} from 'graphql';
+import { ApolloServerValidationErrorCode } from '../errors/index.js';
+
+export const NoIntrospection: ValidationRule = (
+  context: ValidationContext,
+) => ({
+  Field(node) {
+    if (node.name.value === '__schema' || node.name.value === '__type') {
+      context.reportError(
+        new GraphQLError(
+          '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',
+          {
+            nodes: [node],
+            extensions: {
+              validationErrorCode:
+                ApolloServerValidationErrorCode.INTROSPECTION_DISABLED,
+            },
+          },
+        ),
+      );
+    }
+  },
+});
diff --git a/packages/server/src/validationRules/RecursiveSelectionsLimit.ts b/packages/server/src/validationRules/RecursiveSelectionsLimit.ts
new file mode 100644
index 000000000..632c2986f
--- /dev/null
+++ b/packages/server/src/validationRules/RecursiveSelectionsLimit.ts
@@ -0,0 +1,198 @@
+import {
+  GraphQLError,
+  type ValidationRule,
+  type ValidationContext,
+  type ASTVisitor,
+} from 'graphql';
+import { ApolloServerValidationErrorCode } from '../errors/index.js';
+
+export const DEFAULT_MAX_RECURSIVE_SELECTIONS = 10_000_000;
+
+interface ExecutableDefinitionInfo {
+  selectionCount: number;
+  fragmentSpreads: Map<string, number>;
+}
+
+class RecursiveSelectionValidationContext {
+  private readonly fragmentInfo: Map<string, ExecutableDefinitionInfo> =
+    new Map();
+  private readonly operationInfo: Map<string | null, ExecutableDefinitionInfo> =
+    new Map();
+  private currentFragment?: string;
+  private currentOperation?: string | null;
+  private readonly fragmentRecursiveSelectionCount: Map<string, number | null> =
+    new Map();
+
+  constructor(
+    private readonly selectionCountLimit: number,
+    private readonly context: ValidationContext,
+  ) {}
+
+  private getExecutionDefinitionInfo(): ExecutableDefinitionInfo | undefined {
+    if (this.currentFragment !== undefined) {
+      let entry = this.fragmentInfo.get(this.currentFragment);
+      if (!entry) {
+        entry = {
+          selectionCount: 0,
+          fragmentSpreads: new Map(),
+        };
+        this.fragmentInfo.set(this.currentFragment, entry);
+      }
+      return entry;
+    }
+    if (this.currentOperation !== undefined) {
+      let entry = this.operationInfo.get(this.currentOperation);
+      if (!entry) {
+        entry = {
+          selectionCount: 0,
+          fragmentSpreads: new Map(),
+        };
+        this.operationInfo.set(this.currentOperation, entry);
+      }
+      return entry;
+    }
+    return undefined;
+  }
+
+  processSelection(fragmentSpreadName?: string) {
+    const definitionInfo = this.getExecutionDefinitionInfo();
+    if (!definitionInfo) {
+      return;
+    }
+    definitionInfo.selectionCount++;
+    if (fragmentSpreadName !== undefined) {
+      let spreadCount =
+        (definitionInfo.fragmentSpreads.get(fragmentSpreadName) ?? 0) + 1;
+      definitionInfo.fragmentSpreads.set(fragmentSpreadName, spreadCount);
+    }
+  }
+
+  enterFragment(fragment: string) {
+    this.currentFragment = fragment;
+  }
+
+  leaveFragment() {
+    this.currentFragment = undefined;
+  }
+
+  enterOperation(operation: string | null) {
+    this.currentOperation = operation;
+  }
+
+  leaveOperation() {
+    this.currentOperation = undefined;
+  }
+
+  computeFragmentRecursiveSelectionsCount(fragment: string): number {
+    const cachedCount = this.fragmentRecursiveSelectionCount.get(fragment);
+    if (cachedCount === null) {
+      // We set "fragmentRecursiveSelectionCount" to "null" for a fragment when
+      // we're in the middle of recursing it, so if we encounter it when getting
+      // a fragment spread, that means we've reached a circular reference. We
+      // don't want to error here, as a separate GraphQL validation checks for
+      // this, so we instead pretend the fragment has zero selections.
+      return 0;
+    }
+    if (cachedCount !== undefined) {
+      return cachedCount;
+    }
+    this.fragmentRecursiveSelectionCount.set(fragment, null);
+    // If "definitionInfo" is "undefined", it means that the fragment spread
+    // refers to a named fragment that has zero selections or doesn't exist. We
+    // don't want to error here, as a separate GraphQL validation checks for
+    // this, so we instead pretend the fragment always has zero selections.
+    const definitionInfo = this.fragmentInfo.get(fragment);
+    let count = 0;
+    if (definitionInfo) {
+      count = definitionInfo.selectionCount;
+      for (const [fragment, spreadCount] of definitionInfo.fragmentSpreads) {
+        count +=
+          spreadCount * this.computeFragmentRecursiveSelectionsCount(fragment);
+      }
+    }
+    this.fragmentRecursiveSelectionCount.set(fragment, count);
+    return count;
+  }
+
+  private reportError(operation: string | null) {
+    const operationName = operation
+      ? `Operation "${operation}"`
+      : 'Anonymous operation';
+    this.context.reportError(
+      new GraphQLError(
+        `${operationName} recursively requests too many selections.`,
+        {
+          nodes: [],
+          extensions: {
+            validationErrorCode:
+              ApolloServerValidationErrorCode.MAX_RECURSIVE_SELECTIONS_EXCEEDED,
+          },
+        },
+      ),
+    );
+  }
+
+  checkLimitExceeded() {
+    for (const [operation, definitionInfo] of this.operationInfo) {
+      let count = definitionInfo.selectionCount;
+      for (const [fragment, spreadCount] of definitionInfo.fragmentSpreads) {
+        count +=
+          spreadCount * this.computeFragmentRecursiveSelectionsCount(fragment);
+      }
+      if (count > this.selectionCountLimit) {
+        this.reportError(operation);
+      }
+    }
+  }
+}
+
+/**
+ * Creates a GraphQL validation rule that imposes a limit on the number of
+ * recursive selections in an operation. This is the number of selections you
+ * would encounter if named fragments were inserted inline whenever a fragment
+ * spread referencing them were encountered.
+ *
+ * @param limit The maximum number of recursive selections in any operation.
+ */
+export function createMaxRecursiveSelectionsRule(
+  limit: number,
+): ValidationRule {
+  return (context: ValidationContext): ASTVisitor => {
+    const selectionContext = new RecursiveSelectionValidationContext(
+      limit,
+      context,
+    );
+    return {
+      Field() {
+        selectionContext.processSelection();
+      },
+      InlineFragment() {
+        selectionContext.processSelection();
+      },
+      FragmentSpread(node) {
+        selectionContext.processSelection(node.name.value);
+      },
+      FragmentDefinition: {
+        enter(node) {
+          selectionContext.enterFragment(node.name.value);
+        },
+        leave() {
+          selectionContext.leaveFragment();
+        },
+      },
+      OperationDefinition: {
+        enter(node) {
+          selectionContext.enterOperation(node.name?.value ?? null);
+        },
+        leave() {
+          selectionContext.leaveOperation();
+        },
+      },
+      Document: {
+        leave() {
+          selectionContext.checkLimitExceeded();
+        },
+      },
+    };
+  };
+}
diff --git a/packages/server/src/validationRules/index.ts b/packages/server/src/validationRules/index.ts
new file mode 100644
index 000000000..801397777
--- /dev/null
+++ b/packages/server/src/validationRules/index.ts
@@ -0,0 +1,5 @@
+export { NoIntrospection } from './NoIntrospection.js';
+export {
+  DEFAULT_MAX_RECURSIVE_SELECTIONS,
+  createMaxRecursiveSelectionsRule,
+} from './RecursiveSelectionsLimit.js';


Test patch:
diff --git a/packages/server/src/__tests__/validationRules/recursiveSelectionsLimit.test.ts b/packages/server/src/__tests__/validationRules/recursiveSelectionsLimit.test.ts
new file mode 100644
index 000000000..905613c4e
--- /dev/null
+++ b/packages/server/src/__tests__/validationRules/recursiveSelectionsLimit.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect } from '@jest/globals';
+import {
+  validate,
+  buildSchema,
+  specifiedRules,
+  parse,
+  GraphQLError,
+} from 'graphql';
+import { createMaxRecursiveSelectionsRule } from '../../validationRules/index.js';
+
+describe('selection limit tests', () => {
+  it('selection limit threshold', () => {
+    const schema = buildSchema(`
+      type Query {
+        user: User
+      }
+
+      type User {
+        id: ID
+        name: String
+        email: String
+        age: Int
+        address: String
+        phone: String
+      }
+    `);
+
+    const query = `
+      query {
+        user {
+          id
+          name
+          ...UserDetails
+        }
+      }
+
+      fragment UserDetails on User {
+        email
+        age
+        ...MoreDetails
+      }
+
+      fragment MoreDetails on User {
+        address
+        phone
+      }
+    `;
+
+    const biggerQuery = `
+      query {
+        user {
+          email
+          age
+          address
+          phone
+          ...UserDetails
+        }
+      }
+
+      fragment UserDetails on User {
+        id
+        name
+        email
+        age
+        ...MoreDetails
+      }
+
+      fragment MoreDetails on User {
+        id
+        name
+        address
+        phone
+      }
+    `;
+
+    const selectionLimitRule = createMaxRecursiveSelectionsRule(10);
+
+    // check the one that exceeds first to ensure that field count gets reset in between validations
+    let errors = validate(schema, parse(biggerQuery), [
+      ...specifiedRules,
+      selectionLimitRule,
+    ]);
+    expect(errors).toEqual([
+      new GraphQLError(
+        'Anonymous operation recursively requests too many selections.',
+      ),
+    ]);
+
+    errors = validate(schema, parse(query), [
+      ...specifiedRules,
+      selectionLimitRule,
+    ]);
+    expect(errors).toEqual([]);
+  });
+});