Skip to content

Commit 02fcf27

Browse files
andimarekclaude
andcommitted
Detect introspection queries dynamically during validation
Instead of pre-scanning the document with containsIntrospectionFields, let checkGoodFaithIntrospection detect introspection queries at validation time when it first encounters __schema or __type on the Query type. At that point it tightens the complexity limits and sets a flag so that subsequent limit breaches throw GoodFaithIntrospectionExceeded directly. This eliminates the pre-scan (which could miss introspection fields hidden inside inline fragments or fragment spreads) and simplifies GraphQL.validate(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b23c591 commit 02fcf27

File tree

4 files changed

+54
-69
lines changed

4 files changed

+54
-69
lines changed

src/main/java/graphql/GraphQL.java

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
import graphql.validation.OperationValidationRule;
3131
import graphql.validation.QueryComplexityLimits;
3232
import graphql.validation.ValidationError;
33-
import graphql.validation.ValidationErrorType;
3433
import org.jspecify.annotations.NullMarked;
3534
import org.jspecify.annotations.NullUnmarked;
3635

@@ -609,32 +608,17 @@ private List<ValidationError> validate(ExecutionInput executionInput, Document d
609608
validationCtx.onDispatched();
610609

611610
Predicate<OperationValidationRule> validationRulePredicate = executionInput.getGraphQLContext().getOrDefault(ParseAndValidate.INTERNAL_VALIDATION_PREDICATE_HINT, r -> true);
612-
Locale locale = executionInput.getLocale() != null ? executionInput.getLocale() : Locale.getDefault();
611+
Locale locale = executionInput.getLocale();
613612
QueryComplexityLimits limits = executionInput.getGraphQLContext().get(QueryComplexityLimits.KEY);
614613

615-
// Good Faith Introspection: apply tighter limits and enable the rule for introspection queries
616-
boolean goodFaithActive = GoodFaithIntrospection.isEnabled(executionInput.getGraphQLContext())
617-
&& GoodFaithIntrospection.containsIntrospectionFields(document);
618-
if (goodFaithActive) {
619-
limits = GoodFaithIntrospection.goodFaithLimits(limits);
620-
} else {
614+
// Good Faith Introspection: disable the rule if good faith is off
615+
if (!GoodFaithIntrospection.isEnabled(executionInput.getGraphQLContext())) {
621616
Predicate<OperationValidationRule> existing = validationRulePredicate;
622617
validationRulePredicate = rule -> rule != OperationValidationRule.GOOD_FAITH_INTROSPECTION && existing.test(rule);
623618
}
624619

625620
List<ValidationError> validationErrors = ParseAndValidate.validate(graphQLSchema, document, validationRulePredicate, locale, limits);
626621

627-
// If good faith is active and a complexity limit error was produced, convert it to a bad faith error
628-
if (goodFaithActive) {
629-
for (ValidationError error : validationErrors) {
630-
if (error.getValidationErrorType() == ValidationErrorType.MaxQueryFieldsExceeded
631-
|| error.getValidationErrorType() == ValidationErrorType.MaxQueryDepthExceeded) {
632-
validationCtx.onCompleted(null, null);
633-
throw GoodFaithIntrospectionExceeded.tooBigOperation(error.getDescription());
634-
}
635-
}
636-
}
637-
638622
validationCtx.onCompleted(validationErrors, null);
639623
return validationErrors;
640624
}

src/main/java/graphql/introspection/GoodFaithIntrospection.java

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,6 @@
44
import graphql.GraphQLContext;
55
import graphql.GraphQLError;
66
import graphql.PublicApi;
7-
import graphql.language.Definition;
8-
import graphql.language.Document;
9-
import graphql.language.Field;
10-
import graphql.language.OperationDefinition;
11-
import graphql.language.Selection;
12-
import graphql.language.SelectionSet;
137
import graphql.language.SourceLocation;
148
import graphql.validation.QueryComplexityLimits;
159
import org.jspecify.annotations.NullMarked;
@@ -89,33 +83,6 @@ public static boolean isEnabled(GraphQLContext graphQLContext) {
8983
return !graphQLContext.getBoolean(GOOD_FAITH_INTROSPECTION_DISABLED, false);
9084
}
9185

92-
/**
93-
* Performs a shallow scan of the document to check if any operation's top-level selections
94-
* contain introspection fields ({@code __schema} or {@code __type}).
95-
*
96-
* @param document the parsed document
97-
*
98-
* @return true if the document contains top-level introspection fields
99-
*/
100-
public static boolean containsIntrospectionFields(Document document) {
101-
for (Definition<?> definition : document.getDefinitions()) {
102-
if (definition instanceof OperationDefinition) {
103-
SelectionSet selectionSet = ((OperationDefinition) definition).getSelectionSet();
104-
if (selectionSet != null) {
105-
for (Selection<?> selection : selectionSet.getSelections()) {
106-
if (selection instanceof Field) {
107-
String name = ((Field) selection).getName();
108-
if ("__schema".equals(name) || "__type".equals(name)) {
109-
return true;
110-
}
111-
}
112-
}
113-
}
114-
}
115-
}
116-
return false;
117-
}
118-
11986
/**
12087
* Returns query complexity limits that are the minimum of the existing limits and the
12188
* good faith introspection limits. This ensures introspection queries are bounded
@@ -125,10 +92,7 @@ public static boolean containsIntrospectionFields(Document document) {
12592
*
12693
* @return complexity limits with good faith bounds applied
12794
*/
128-
public static QueryComplexityLimits goodFaithLimits(@Nullable QueryComplexityLimits existing) {
129-
if (existing == null) {
130-
existing = QueryComplexityLimits.getDefaultLimits();
131-
}
95+
public static QueryComplexityLimits goodFaithLimits(QueryComplexityLimits existing) {
13296
int maxFields = Math.min(existing.getMaxFieldsCount(), GOOD_FAITH_MAX_FIELDS_COUNT);
13397
int maxDepth = Math.min(existing.getMaxDepth(), GOOD_FAITH_MAX_DEPTH_COUNT);
13498
return QueryComplexityLimits.newLimits()

src/main/java/graphql/validation/OperationValidator.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import graphql.execution.TypeFromAST;
1515
import graphql.execution.ValuesResolver;
1616
import graphql.i18n.I18nMsg;
17+
import graphql.introspection.GoodFaithIntrospection;
1718
import graphql.introspection.Introspection.DirectiveLocation;
1819
import graphql.language.Argument;
1920
import graphql.language.AstComparator;
@@ -332,14 +333,15 @@ public class OperationValidator implements DocumentVisitor {
332333
private int fieldCount = 0;
333334
private int currentFieldDepth = 0;
334335
private int maxFieldDepthSeen = 0;
335-
private final QueryComplexityLimits complexityLimits;
336+
private QueryComplexityLimits complexityLimits;
336337
// Fragment complexity calculated lazily during first spread
337338
private final Map<String, FragmentComplexityInfo> fragmentComplexityMap = new HashMap<>();
338339
// Max depth seen during current fragment traversal (for calculating fragment's internal depth)
339340
private int fragmentTraversalMaxDepth = 0;
340341

341342
// --- State: Good Faith Introspection ---
342343
private final Map<String, Integer> introspectionFieldCounts = new HashMap<>();
344+
private boolean introspectionQueryDetected = false;
343345

344346
// --- Track whether we're in a context where fragment spread rules should run ---
345347
// fragmentRetraversalDepth == 0 means we're NOT inside a manually-traversed fragment => run non-fragment-spread checks
@@ -406,6 +408,10 @@ private boolean shouldRunOperationScopedRules() {
406408

407409
private void checkFieldCountLimit() {
408410
if (fieldCount > complexityLimits.getMaxFieldsCount()) {
411+
if (introspectionQueryDetected) {
412+
throw GoodFaithIntrospectionExceeded.tooBigOperation(
413+
"Query has " + fieldCount + " fields which exceeds maximum allowed " + complexityLimits.getMaxFieldsCount());
414+
}
409415
throw new QueryComplexityLimitsExceeded(
410416
ValidationErrorType.MaxQueryFieldsExceeded,
411417
complexityLimits.getMaxFieldsCount(),
@@ -417,6 +423,10 @@ private void checkDepthLimit(int depth) {
417423
if (depth > maxFieldDepthSeen) {
418424
maxFieldDepthSeen = depth;
419425
if (maxFieldDepthSeen > complexityLimits.getMaxDepth()) {
426+
if (introspectionQueryDetected) {
427+
throw GoodFaithIntrospectionExceeded.tooBigOperation(
428+
"Query depth " + maxFieldDepthSeen + " exceeds maximum allowed depth " + complexityLimits.getMaxDepth());
429+
}
420430
throw new QueryComplexityLimitsExceeded(
421431
ValidationErrorType.MaxQueryDepthExceeded,
422432
complexityLimits.getMaxDepth(),
@@ -629,6 +639,10 @@ private void checkGoodFaithIntrospection(Field field) {
629639
if (queryType != null && parentType.getName().equals(queryType.getName())) {
630640
if ("__schema".equals(fieldName) || "__type".equals(fieldName)) {
631641
key = parentType.getName() + "." + fieldName;
642+
if (!introspectionQueryDetected) {
643+
introspectionQueryDetected = true;
644+
complexityLimits = GoodFaithIntrospection.goodFaithLimits(complexityLimits);
645+
}
632646
}
633647
}
634648
}

src/test/groovy/graphql/introspection/GoodFaithIntrospectionTest.groovy

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,41 @@ class GoodFaithIntrospectionTest extends Specification {
189189
100 | GoodFaithIntrospection.BadFaithIntrospectionError.class
190190
}
191191

192+
def "introspection via inline fragment on Query is detected as bad faith"() {
193+
def query = """
194+
query badActor {
195+
...on Query {
196+
__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}
197+
}
198+
}
199+
"""
200+
201+
when:
202+
ExecutionResult er = graphql.execute(query)
203+
204+
then:
205+
!er.errors.isEmpty()
206+
er.errors[0] instanceof GoodFaithIntrospection.BadFaithIntrospectionError
207+
}
208+
209+
def "introspection via fragment spread is detected as bad faith"() {
210+
def query = """
211+
query badActor {
212+
...IntrospectionFragment
213+
}
214+
fragment IntrospectionFragment on Query {
215+
__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}
216+
}
217+
"""
218+
219+
when:
220+
ExecutionResult er = graphql.execute(query)
221+
222+
then:
223+
!er.errors.isEmpty()
224+
er.errors[0] instanceof GoodFaithIntrospection.BadFaithIntrospectionError
225+
}
226+
192227
def "good faith limits are applied on top of custom user limits"() {
193228
given:
194229
def limits = QueryComplexityLimits.newLimits().maxFieldsCount(200).maxDepth(15).build()
@@ -203,18 +238,6 @@ class GoodFaithIntrospectionTest extends Specification {
203238
er.errors.isEmpty()
204239
}
205240

206-
def "containsIntrospectionFields handles operation with no selection set"() {
207-
given:
208-
def op = graphql.language.OperationDefinition.newOperationDefinition()
209-
.name("empty")
210-
.operation(graphql.language.OperationDefinition.Operation.QUERY)
211-
.build()
212-
def doc = Document.newDocument().definition(op).build()
213-
214-
expect:
215-
!GoodFaithIntrospection.containsIntrospectionFields(doc)
216-
}
217-
218241
def "introspection query exceeding field count limit is detected as bad faith"() {
219242
given:
220243
// Build a wide introspection query that exceeds GOOD_FAITH_MAX_FIELDS_COUNT (500)

0 commit comments

Comments
 (0)