Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions graphql/server/src/protection/__tests__/document-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
8 changes: 8 additions & 0 deletions graphql/server/src/protection/document-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -119,6 +126,7 @@ function walkSelectionSet(
if (!walk.protection.enableIntrospection) {
reject(errors.INTROSPECTION_DISABLED());
}
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 security · medium

Introspection continue bypasses the depth budget

At graphql/server/src/protection/document-gate.ts:129, the new continue skips the entire __schema/__type selection subtree, so it is never charged depth or cost. Because the introspection schema is recursive (__Type.fields__Field.type__Type), a client can send an arbitrarily deep introspection document that the gate reports as { depth: 1, cost: 0 }, defeating the maxQueryDepth DoS protection; only the statement timeout still bounds it.

📋 Prompt for AI Agents

In graphql/server/src/protection/document-gate.ts around line 129, the continue after the __schema/__type introspection check skips the entire selection subtree, so a recursive introspection document (__schema { types { fields { type { fields { type { ... } } } } } }) bypasses maxQueryDepth and is reported as depth 1. Replace the bare continue with logic that still walks the introspection selection set but caps the depth contributed by introspection recursion (for example, recurse into the __schema/__type selection set with a fixed depth allowance independent of maxQueryDepth, or track an introspection-specific depth counter and reject when it exceeds a bounded constant). Keep exempting introspection from cost, but do not let it escape depth bounding entirely.

}

const field =
Expand Down
Loading