Skip to content

Commit dc3fe40

Browse files
committed
Backport PR 3527 GoodFaithIntrospection
1 parent c4d66e9 commit dc3fe40

6 files changed

Lines changed: 302 additions & 21 deletions

File tree

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

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

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

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

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

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

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import graphql.GraphQLContext;
99
import graphql.Internal;
1010
import graphql.PublicApi;
11+
import graphql.execution.ExecutionContext;
1112
import graphql.execution.MergedField;
1213
import graphql.execution.MergedSelectionSet;
1314
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
@@ -1324,17 +1324,10 @@ class ExecutableNormalizedOperationToAstCompilerTest extends Specification {
13241324
'''
13251325
}
13261326

1327-
def "introspection query can be printed"() {
1327+
def "introspection query can be printed __schema"() {
13281328
def sdl = '''
13291329
type Query {
1330-
foo1: Foo
1331-
}
1332-
interface Foo {
1333-
test: String
1334-
}
1335-
type AFoo implements Foo {
1336-
test: String
1337-
aFoo: String
1330+
f: String
13381331
}
13391332
'''
13401333
def query = '''
@@ -1346,14 +1339,7 @@ class ExecutableNormalizedOperationToAstCompilerTest extends Specification {
13461339
}
13471340
}
13481341
}
1349-
1350-
__type(name: "World") {
1351-
name
1352-
fields {
1353-
name
1354-
}
1355-
}
1356-
}
1342+
}
13571343
'''
13581344

13591345
GraphQLSchema schema = mkSchema(sdl)
@@ -1370,6 +1356,34 @@ class ExecutableNormalizedOperationToAstCompilerTest extends Specification {
13701356
}
13711357
}
13721358
}
1359+
}
1360+
'''
1361+
}
1362+
1363+
def "introspection query can be printed __type"() {
1364+
def sdl = '''
1365+
type Query {
1366+
f: String
1367+
}
1368+
'''
1369+
def query = '''
1370+
query introspection_query {
1371+
__type(name: "World") {
1372+
name
1373+
fields {
1374+
name
1375+
}
1376+
}
1377+
}
1378+
'''
1379+
1380+
GraphQLSchema schema = mkSchema(sdl)
1381+
def fields = createNormalizedFields(schema, query)
1382+
when:
1383+
def result = compileToDocument(schema, QUERY, null, fields, noVariables)
1384+
def documentPrinted = AstPrinter.printAst(new AstSorter().sort(result.document))
1385+
then:
1386+
documentPrinted == '''{
13731387
__type(name: "World") {
13741388
fields {
13751389
name

0 commit comments

Comments
 (0)