From 6c737cb68e910ee450ea86e70a2752e69570ccbf Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Tue, 8 Sep 2026 21:32:11 +0000 Subject: [PATCH] fix(graphql-server): exempt schema introspection from the depth and cost gate The standard introspection document nests __schema > types > fields > args > type > ofType x7 (depth 13), which the default max_query_depth of 12 rejects, so every codegen run against a protected endpoint failed with QUERY_TOO_DEEP. Introspection is governed by enable_introspection alone; its selections carry no connections and are not walked. --- .../__tests__/document-gate.test.ts | 22 +++++++++++++++++++ .../server/src/protection/document-gate.ts | 8 +++++++ 2 files changed, 30 insertions(+) diff --git a/graphql/server/src/protection/__tests__/document-gate.test.ts b/graphql/server/src/protection/__tests__/document-gate.test.ts index 40f686b7c..3d321387b 100644 --- a/graphql/server/src/protection/__tests__/document-gate.test.ts +++ b/graphql/server/src/protection/__tests__/document-gate.test.ts @@ -181,6 +181,28 @@ describe('introspection', () => { expect(() => enforce('{ __schema { queryType { name } } }')).not.toThrow(); }); + it('does not charge the standard introspection document against the depth budget', () => { + const source = ` + { __schema { types { fields { args { type { ...TypeRef } } } } } } + fragment TypeRef on __Type { + kind name + ofType { kind name ofType { kind name ofType { kind name ofType { kind name + ofType { kind name ofType { kind name ofType { kind name } } } } } } } + } + `; + expect(enforce(source, { maxQueryDepth: 3 })).toEqual({ depth: 1, cost: 0 }); + }); + + it('still measures the rest of an operation that also introspects', () => { + expect( + codeOf(() => + enforce('{ __schema { queryType { name } } user(id: "1") { manager { manager { name } } } }', { + maxQueryDepth: 3 + }) + ) + ).toBe('QUERY_TOO_DEEP'); + }); + it('never blocks __typename, which is not introspection of the schema', () => { expect(() => enforce('{ user(id: "1") { __typename name } }', { enableIntrospection: false }) diff --git a/graphql/server/src/protection/document-gate.ts b/graphql/server/src/protection/document-gate.ts index aaca15d4e..9ecbd96ac 100644 --- a/graphql/server/src/protection/document-gate.ts +++ b/graphql/server/src/protection/document-gate.ts @@ -16,6 +16,13 @@ * The walk is manual rather than `visitWithTypeInfo` because fragment spreads * have to be followed (a document can hide its depth entirely inside * fragments) and the visitor does not follow them. + * + * Schema introspection (`__schema` / `__type`) is governed only by + * `enableIntrospection`. Its selections are not walked: the introspection types + * carry no connections, so there is nothing to cost, and the standard + * introspection document nests a fixed `ofType` chain deeper than a sensible + * tenant depth budget, so charging depth would make the budget decide whether + * clients can introspect at all — a decision the dedicated switch already owns. */ import type { ConstructiveError } from '@constructive-io/errors'; @@ -119,6 +126,7 @@ function walkSelectionSet( if (!walk.protection.enableIntrospection) { reject(errors.INTROSPECTION_DISABLED()); } + continue; } const field =