Skip to content

Commit 189a570

Browse files
authored
Merge pull request #3527 from graphql-java/good-faith-introspection
This provides GoodFaithIntrospection
2 parents 776de2f + e51361b commit 189a570

File tree

6 files changed

+299
-21
lines changed

6 files changed

+299
-21
lines changed

src/main/java/graphql/execution/AsyncExecutionStrategy.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public CompletableFuture<ExecutionResult> execute(ExecutionContext executionCont
4848
MergedSelectionSet fields = parameters.getFields();
4949
List<String> fieldNames = fields.getKeys();
5050

51-
Optional<ExecutionResult> isNotSensible = Introspection.isIntrospectionSensible(executionContext.getGraphQLContext(),fields);
51+
Optional<ExecutionResult> isNotSensible = Introspection.isIntrospectionSensible(fields, executionContext);
5252
if (isNotSensible.isPresent()) {
5353
return CompletableFuture.completedFuture(isNotSensible.get());
5454
}

src/main/java/graphql/execution/AsyncSerialExecutionStrategy.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public CompletableFuture<ExecutionResult> execute(ExecutionContext executionCont
4444

4545
// this is highly unlikely since Mutations cant do introspection BUT in theory someone could make the query strategy this code
4646
// so belts and braces
47-
Optional<ExecutionResult> isNotSensible = Introspection.isIntrospectionSensible(executionContext.getGraphQLContext(), fields);
47+
Optional<ExecutionResult> isNotSensible = Introspection.isIntrospectionSensible(fields, executionContext);
4848
if (isNotSensible.isPresent()) {
4949
return CompletableFuture.completedFuture(isNotSensible.get());
5050
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package graphql.introspection;
2+
3+
import com.google.common.collect.ImmutableList;
4+
import com.google.common.collect.ImmutableListMultimap;
5+
import graphql.ErrorClassification;
6+
import graphql.ExecutionResult;
7+
import graphql.GraphQLContext;
8+
import graphql.GraphQLError;
9+
import graphql.PublicApi;
10+
import graphql.execution.ExecutionContext;
11+
import graphql.language.SourceLocation;
12+
import graphql.normalized.ExecutableNormalizedField;
13+
import graphql.normalized.ExecutableNormalizedOperation;
14+
import graphql.schema.FieldCoordinates;
15+
16+
import java.util.List;
17+
import java.util.Map;
18+
import java.util.Optional;
19+
import java.util.concurrent.atomic.AtomicBoolean;
20+
21+
import static graphql.schema.FieldCoordinates.coordinates;
22+
23+
/**
24+
* This {@link graphql.execution.instrumentation.Instrumentation} ensure that a submitted introspection query is done in
25+
* good faith.
26+
* <p>
27+
* There are attack vectors where a crafted introspection query can cause the engine to spend too much time
28+
* producing introspection data. This is especially true on large schemas with lots of types and fields.
29+
* <p>
30+
* Schemas form a cyclic graph and hence it's possible to send in introspection queries that can reference those cycles
31+
* and in large schemas this can be expensive and perhaps a "denial of service".
32+
* <p>
33+
* This instrumentation only allows one __schema field or one __type field to be present, and it does not allow the `__Type` fields
34+
* to form a cycle, i.e., that can only be present once. This allows the standard and common introspection queries to work
35+
* so tooling such as graphiql can work.
36+
*/
37+
@PublicApi
38+
public class GoodFaithIntrospection {
39+
40+
/**
41+
* Placing a boolean value under this key in the per request {@link GraphQLContext} will enable
42+
* or disable Good Faith Introspection on that request.
43+
*/
44+
public static final String GOOD_FAITH_INTROSPECTION_DISABLED = "GOOD_FAITH_INTROSPECTION_DISABLED";
45+
46+
private static final AtomicBoolean ENABLED_STATE = new AtomicBoolean(true);
47+
48+
/**
49+
* @return true if good faith introspection is enabled
50+
*/
51+
public static boolean isEnabledJvmWide() {
52+
return ENABLED_STATE.get();
53+
}
54+
55+
/**
56+
* This allows you to disable good faith introspection, which is on by default.
57+
*
58+
* @param flag the desired state
59+
*
60+
* @return the previous state
61+
*/
62+
public static boolean enabledJvmWide(boolean flag) {
63+
return ENABLED_STATE.getAndSet(flag);
64+
}
65+
66+
private static final Map<FieldCoordinates, Integer> ALLOWED_FIELD_INSTANCES = Map.of(
67+
coordinates("Query", "__schema"), 1
68+
, coordinates("Query", "__type"), 1
69+
70+
, coordinates("__Type", "fields"), 1
71+
, coordinates("__Type", "inputFields"), 1
72+
, coordinates("__Type", "interfaces"), 1
73+
, coordinates("__Type", "possibleTypes"), 1
74+
);
75+
76+
public static Optional<ExecutionResult> checkIntrospection(ExecutionContext executionContext) {
77+
if (isIntrospectionEnabled(executionContext.getGraphQLContext())) {
78+
ExecutableNormalizedOperation operation = executionContext.getNormalizedQueryTree().get();
79+
ImmutableListMultimap<FieldCoordinates, ExecutableNormalizedField> coordinatesToENFs = operation.getCoordinatesToNormalizedFields();
80+
for (Map.Entry<FieldCoordinates, Integer> entry : ALLOWED_FIELD_INSTANCES.entrySet()) {
81+
FieldCoordinates coordinates = entry.getKey();
82+
Integer allowSize = entry.getValue();
83+
ImmutableList<ExecutableNormalizedField> normalizedFields = coordinatesToENFs.get(coordinates);
84+
if (normalizedFields.size() > allowSize) {
85+
BadFaithIntrospectionError error = new BadFaithIntrospectionError(coordinates.toString());
86+
return Optional.of(ExecutionResult.newExecutionResult().addError(error).build());
87+
}
88+
}
89+
}
90+
return Optional.empty();
91+
}
92+
93+
private static boolean isIntrospectionEnabled(GraphQLContext graphQlContext) {
94+
if (!isEnabledJvmWide()) {
95+
return false;
96+
}
97+
return !graphQlContext.getOrDefault(GOOD_FAITH_INTROSPECTION_DISABLED, false);
98+
}
99+
100+
public static class BadFaithIntrospectionError implements GraphQLError {
101+
private final String message;
102+
103+
public BadFaithIntrospectionError(String qualifiedField) {
104+
this.message = String.format("This request is not asking for introspection in good faith - %s is present too often!", qualifiedField);
105+
}
106+
107+
@Override
108+
public String getMessage() {
109+
return message;
110+
}
111+
112+
@Override
113+
public ErrorClassification getErrorType() {
114+
return ErrorClassification.errorClassification("BadFaithIntrospection");
115+
}
116+
117+
@Override
118+
public List<SourceLocation> getLocations() {
119+
return null;
120+
}
121+
}
122+
}

src/main/java/graphql/introspection/Introspection.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import graphql.GraphQLContext;
88
import graphql.Internal;
99
import graphql.PublicApi;
10+
import graphql.execution.ExecutionContext;
1011
import graphql.execution.MergedField;
1112
import graphql.execution.MergedSelectionSet;
1213
import graphql.execution.ValuesResolver;
@@ -108,10 +109,12 @@ public static boolean isEnabledJvmWide() {
108109
* that can be returned to the user.
109110
*
110111
* @param mergedSelectionSet the fields to be executed
112+
* @param executionContext the execution context in play
111113
*
112114
* @return an optional error result
113115
*/
114-
public static Optional<ExecutionResult> isIntrospectionSensible(GraphQLContext graphQLContext, MergedSelectionSet mergedSelectionSet) {
116+
public static Optional<ExecutionResult> isIntrospectionSensible(MergedSelectionSet mergedSelectionSet, ExecutionContext executionContext) {
117+
GraphQLContext graphQLContext = executionContext.getGraphQLContext();
115118
MergedField schemaField = mergedSelectionSet.getSubField(SchemaMetaFieldDef.getName());
116119
if (schemaField != null) {
117120
if (!isIntrospectionEnabled(graphQLContext)) {
@@ -124,7 +127,10 @@ public static Optional<ExecutionResult> isIntrospectionSensible(GraphQLContext g
124127
return mkDisabledError(typeField);
125128
}
126129
}
127-
// later we can put a good faith check code here to check the fields make sense
130+
if (schemaField != null || typeField != null)
131+
{
132+
return GoodFaithIntrospection.checkIntrospection(executionContext);
133+
}
128134
return Optional.empty();
129135
}
130136

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package graphql.introspection
2+
3+
import graphql.ExecutionInput
4+
import graphql.ExecutionResult
5+
import graphql.TestUtil
6+
import spock.lang.Specification
7+
8+
class GoodFaithIntrospectionInstrumentationTest extends Specification {
9+
10+
def graphql = TestUtil.graphQL("type Query { normalField : String }").build()
11+
12+
def setup() {
13+
GoodFaithIntrospection.enabledJvmWide(true)
14+
}
15+
def cleanup() {
16+
GoodFaithIntrospection.enabledJvmWide(true)
17+
}
18+
19+
def "test asking for introspection in good faith"() {
20+
21+
when:
22+
ExecutionResult er = graphql.execute(IntrospectionQuery.INTROSPECTION_QUERY)
23+
then:
24+
er.errors.isEmpty()
25+
}
26+
27+
def "test asking for introspection in bad faith"() {
28+
29+
when:
30+
ExecutionResult er = graphql.execute(query)
31+
then:
32+
!er.errors.isEmpty()
33+
er.errors[0] instanceof GoodFaithIntrospection.BadFaithIntrospectionError
34+
35+
where:
36+
query | _
37+
// long attack
38+
"""
39+
query badActor{__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}}
40+
""" | _
41+
// a case for __Type interfaces
42+
""" query badActor {
43+
__schema { types { interfaces { fields { type { interfaces { name } } } } } }
44+
}
45+
""" | _
46+
// a case for __Type inputFields
47+
""" query badActor {
48+
__schema { types { inputFields { type { inputFields { name }}}}}
49+
}
50+
""" | _
51+
// a case for __Type possibleTypes
52+
""" query badActor {
53+
__schema { types { inputFields { type { inputFields { name }}}}}
54+
}
55+
""" | _
56+
// a case leading from __InputValue
57+
""" query badActor {
58+
__schema { types { fields { args { type { name fields { name }}}}}}
59+
}
60+
""" | _
61+
// a case leading from __Field
62+
""" query badActor {
63+
__schema { types { fields { type { name fields { name }}}}}
64+
}
65+
""" | _
66+
// a case for __type
67+
""" query badActor {
68+
__type(name : "t") { name }
69+
alias1 : __type(name : "t1") { name }
70+
}
71+
""" | _
72+
// a case for schema repeated - dont ask twice
73+
""" query badActor {
74+
__schema { types { name} }
75+
alias1 : __schema { types { name} }
76+
}
77+
""" | _
78+
}
79+
80+
def "mixed general queries and introspections will be stopped anyway"() {
81+
def query = """
82+
query goodAndBad {
83+
normalField
84+
__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}
85+
}
86+
"""
87+
88+
when:
89+
ExecutionResult er = graphql.execute(query)
90+
then:
91+
!er.errors.isEmpty()
92+
er.errors[0] instanceof GoodFaithIntrospection.BadFaithIntrospectionError
93+
er.data == null // it stopped hard - it did not continue to normal business
94+
}
95+
96+
def "can be disabled"() {
97+
when:
98+
def currentState = GoodFaithIntrospection.isEnabledJvmWide()
99+
100+
then:
101+
currentState
102+
103+
when:
104+
def prevState = GoodFaithIntrospection.enabledJvmWide(false)
105+
106+
then:
107+
prevState
108+
109+
when:
110+
ExecutionResult er = graphql.execute("query badActor{__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}}")
111+
112+
then:
113+
er.errors.isEmpty()
114+
}
115+
116+
def "can be disabled per request"() {
117+
when:
118+
def context = [(GoodFaithIntrospection.GOOD_FAITH_INTROSPECTION_DISABLED): true]
119+
ExecutionInput executionInput = ExecutionInput.newExecutionInput("query badActor{__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}}")
120+
.graphQLContext(context).build()
121+
ExecutionResult er = graphql.execute(executionInput)
122+
123+
then:
124+
er.errors.isEmpty()
125+
126+
when:
127+
context = [(GoodFaithIntrospection.GOOD_FAITH_INTROSPECTION_DISABLED): false]
128+
executionInput = ExecutionInput.newExecutionInput("query badActor{__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}}")
129+
.graphQLContext(context).build()
130+
er = graphql.execute(executionInput)
131+
132+
then:
133+
!er.errors.isEmpty()
134+
er.errors[0] instanceof GoodFaithIntrospection.BadFaithIntrospectionError
135+
}
136+
}

src/test/groovy/graphql/normalized/ExecutableNormalizedOperationToAstCompilerTest.groovy

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,17 +1387,10 @@ abstract class ExecutableNormalizedOperationToAstCompilerTest extends Specificat
13871387
'''
13881388
}
13891389

1390-
def "introspection query can be printed"() {
1390+
def "introspection query can be printed __schema"() {
13911391
def sdl = '''
13921392
type Query {
1393-
foo1: Foo
1394-
}
1395-
interface Foo {
1396-
test: String
1397-
}
1398-
type AFoo implements Foo {
1399-
test: String
1400-
aFoo: String
1393+
f: String
14011394
}
14021395
'''
14031396
def query = '''
@@ -1409,14 +1402,7 @@ abstract class ExecutableNormalizedOperationToAstCompilerTest extends Specificat
14091402
}
14101403
}
14111404
}
1412-
1413-
__type(name: "World") {
1414-
name
1415-
fields {
1416-
name
1417-
}
1418-
}
1419-
}
1405+
}
14201406
'''
14211407

14221408
GraphQLSchema schema = mkSchema(sdl)
@@ -1433,6 +1419,34 @@ abstract class ExecutableNormalizedOperationToAstCompilerTest extends Specificat
14331419
}
14341420
}
14351421
}
1422+
}
1423+
'''
1424+
}
1425+
1426+
def "introspection query can be printed __type"() {
1427+
def sdl = '''
1428+
type Query {
1429+
f: String
1430+
}
1431+
'''
1432+
def query = '''
1433+
query introspection_query {
1434+
__type(name: "World") {
1435+
name
1436+
fields {
1437+
name
1438+
}
1439+
}
1440+
}
1441+
'''
1442+
1443+
GraphQLSchema schema = mkSchema(sdl)
1444+
def fields = createNormalizedFields(schema, query)
1445+
when:
1446+
def result = localCompileToDocument(schema, QUERY, null, fields, noVariables)
1447+
def documentPrinted = AstPrinter.printAst(new AstSorter().sort(result.document))
1448+
then:
1449+
documentPrinted == '''{
14361450
__type(name: "World") {
14371451
fields {
14381452
name

0 commit comments

Comments
 (0)