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
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public CompletableFuture<ExecutionResult> execute(ExecutionContext executionCont
MergedSelectionSet fields = parameters.getFields();
List<String> fieldNames = fields.getKeys();

Optional<ExecutionResult> isNotSensible = Introspection.isIntrospectionSensible(executionContext.getGraphQLContext(),fields);
Optional<ExecutionResult> isNotSensible = Introspection.isIntrospectionSensible(fields, executionContext);
if (isNotSensible.isPresent()) {
return CompletableFuture.completedFuture(isNotSensible.get());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public CompletableFuture<ExecutionResult> execute(ExecutionContext executionCont

// this is highly unlikely since Mutations cant do introspection BUT in theory someone could make the query strategy this code
// so belts and braces
Optional<ExecutionResult> isNotSensible = Introspection.isIntrospectionSensible(executionContext.getGraphQLContext(), fields);
Optional<ExecutionResult> isNotSensible = Introspection.isIntrospectionSensible(fields, executionContext);
if (isNotSensible.isPresent()) {
return CompletableFuture.completedFuture(isNotSensible.get());
}
Expand Down
122 changes: 122 additions & 0 deletions src/main/java/graphql/introspection/GoodFaithIntrospection.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package graphql.introspection;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import graphql.ErrorClassification;
import graphql.ExecutionResult;
import graphql.GraphQLContext;
import graphql.GraphQLError;
import graphql.PublicApi;
import graphql.execution.ExecutionContext;
import graphql.language.SourceLocation;
import graphql.normalized.ExecutableNormalizedField;
import graphql.normalized.ExecutableNormalizedOperation;
import graphql.schema.FieldCoordinates;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;

import static graphql.schema.FieldCoordinates.coordinates;

/**
* This {@link graphql.execution.instrumentation.Instrumentation} ensure that a submitted introspection query is done in
* good faith.
* <p>
* There are attack vectors where a crafted introspection query can cause the engine to spend too much time
* producing introspection data. This is especially true on large schemas with lots of types and fields.
* <p>
* Schemas form a cyclic graph and hence it's possible to send in introspection queries that can reference those cycles
* and in large schemas this can be expensive and perhaps a "denial of service".
* <p>
* This instrumentation only allows one __schema field or one __type field to be present, and it does not allow the `__Type` fields
* to form a cycle, i.e., that can only be present once. This allows the standard and common introspection queries to work
* so tooling such as graphiql can work.
*/
@PublicApi
public class GoodFaithIntrospection {

/**
* Placing a boolean value under this key in the per request {@link GraphQLContext} will enable
* or disable Good Faith Introspection on that request.
*/
public static final String GOOD_FAITH_INTROSPECTION_DISABLED = "GOOD_FAITH_INTROSPECTION_DISABLED";

private static final AtomicBoolean ENABLED_STATE = new AtomicBoolean(true);

/**
* @return true if good faith introspection is enabled
*/
public static boolean isEnabledJvmWide() {
return ENABLED_STATE.get();
}

/**
* This allows you to disable good faith introspection, which is on by default.
*
* @param flag the desired state
*
* @return the previous state
*/
public static boolean enabledJvmWide(boolean flag) {
return ENABLED_STATE.getAndSet(flag);
}

private static final Map<FieldCoordinates, Integer> ALLOWED_FIELD_INSTANCES = Map.of(
coordinates("Query", "__schema"), 1
, coordinates("Query", "__type"), 1

, coordinates("__Type", "fields"), 1
, coordinates("__Type", "inputFields"), 1
, coordinates("__Type", "interfaces"), 1
, coordinates("__Type", "possibleTypes"), 1
);

public static Optional<ExecutionResult> checkIntrospection(ExecutionContext executionContext) {
if (isIntrospectionEnabled(executionContext.getGraphQLContext())) {
ExecutableNormalizedOperation operation = executionContext.getNormalizedQueryTree().get();
ImmutableListMultimap<FieldCoordinates, ExecutableNormalizedField> coordinatesToENFs = operation.getCoordinatesToNormalizedFields();
for (Map.Entry<FieldCoordinates, Integer> entry : ALLOWED_FIELD_INSTANCES.entrySet()) {
FieldCoordinates coordinates = entry.getKey();
Integer allowSize = entry.getValue();
ImmutableList<ExecutableNormalizedField> normalizedFields = coordinatesToENFs.get(coordinates);
if (normalizedFields.size() > allowSize) {
BadFaithIntrospectionError error = new BadFaithIntrospectionError(coordinates.toString());
return Optional.of(ExecutionResult.newExecutionResult().addError(error).build());
}
}
}
return Optional.empty();
}

private static boolean isIntrospectionEnabled(GraphQLContext graphQlContext) {
if (!isEnabledJvmWide()) {
return false;
}
return !graphQlContext.getOrDefault(GOOD_FAITH_INTROSPECTION_DISABLED, false);
}

public static class BadFaithIntrospectionError implements GraphQLError {
private final String message;

public BadFaithIntrospectionError(String qualifiedField) {
this.message = String.format("This request is not asking for introspection in good faith - %s is present too often!", qualifiedField);
}

@Override
public String getMessage() {
return message;
}

@Override
public ErrorClassification getErrorType() {
return ErrorClassification.errorClassification("BadFaithIntrospection");
}

@Override
public List<SourceLocation> getLocations() {
return null;
}
}
}
10 changes: 8 additions & 2 deletions src/main/java/graphql/introspection/Introspection.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import graphql.GraphQLContext;
import graphql.Internal;
import graphql.PublicApi;
import graphql.execution.ExecutionContext;
import graphql.execution.MergedField;
import graphql.execution.MergedSelectionSet;
import graphql.execution.ValuesResolver;
Expand Down Expand Up @@ -108,10 +109,12 @@ public static boolean isEnabledJvmWide() {
* that can be returned to the user.
*
* @param mergedSelectionSet the fields to be executed
* @param executionContext the execution context in play
*
* @return an optional error result
*/
public static Optional<ExecutionResult> isIntrospectionSensible(GraphQLContext graphQLContext, MergedSelectionSet mergedSelectionSet) {
public static Optional<ExecutionResult> isIntrospectionSensible(MergedSelectionSet mergedSelectionSet, ExecutionContext executionContext) {
GraphQLContext graphQLContext = executionContext.getGraphQLContext();
MergedField schemaField = mergedSelectionSet.getSubField(SchemaMetaFieldDef.getName());
if (schemaField != null) {
if (!isIntrospectionEnabled(graphQLContext)) {
Expand All @@ -124,7 +127,10 @@ public static Optional<ExecutionResult> isIntrospectionSensible(GraphQLContext g
return mkDisabledError(typeField);
}
}
// later we can put a good faith check code here to check the fields make sense
if (schemaField != null || typeField != null)
{
return GoodFaithIntrospection.checkIntrospection(executionContext);
}
return Optional.empty();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package graphql.introspection

import graphql.ExecutionInput
import graphql.ExecutionResult
import graphql.TestUtil
import spock.lang.Specification

class GoodFaithIntrospectionInstrumentationTest extends Specification {

def graphql = TestUtil.graphQL("type Query { normalField : String }").build()

def setup() {
GoodFaithIntrospection.enabledJvmWide(true)
}
def cleanup() {
GoodFaithIntrospection.enabledJvmWide(true)
}

def "test asking for introspection in good faith"() {

when:
ExecutionResult er = graphql.execute(IntrospectionQuery.INTROSPECTION_QUERY)
then:
er.errors.isEmpty()
}

def "test asking for introspection in bad faith"() {

when:
ExecutionResult er = graphql.execute(query)
then:
!er.errors.isEmpty()
er.errors[0] instanceof GoodFaithIntrospection.BadFaithIntrospectionError

where:
query | _
// long attack
"""
query badActor{__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}}
""" | _
// a case for __Type interfaces
""" query badActor {
__schema { types { interfaces { fields { type { interfaces { name } } } } } }
}
""" | _
// a case for __Type inputFields
""" query badActor {
__schema { types { inputFields { type { inputFields { name }}}}}
}
""" | _
// a case for __Type possibleTypes
""" query badActor {
__schema { types { inputFields { type { inputFields { name }}}}}
}
""" | _
// a case leading from __InputValue
""" query badActor {
__schema { types { fields { args { type { name fields { name }}}}}}
}
""" | _
// a case leading from __Field
""" query badActor {
__schema { types { fields { type { name fields { name }}}}}
}
""" | _
// a case for __type
""" query badActor {
__type(name : "t") { name }
alias1 : __type(name : "t1") { name }
}
""" | _
// a case for schema repeated - dont ask twice
""" query badActor {
__schema { types { name} }
alias1 : __schema { types { name} }
}
""" | _
}

def "mixed general queries and introspections will be stopped anyway"() {
def query = """
query goodAndBad {
normalField
__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}
}
"""

when:
ExecutionResult er = graphql.execute(query)
then:
!er.errors.isEmpty()
er.errors[0] instanceof GoodFaithIntrospection.BadFaithIntrospectionError
er.data == null // it stopped hard - it did not continue to normal business
}

def "can be disabled"() {
when:
def currentState = GoodFaithIntrospection.isEnabledJvmWide()

then:
currentState

when:
def prevState = GoodFaithIntrospection.enabledJvmWide(false)

then:
prevState

when:
ExecutionResult er = graphql.execute("query badActor{__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}}")

then:
er.errors.isEmpty()
}

def "can be disabled per request"() {
when:
def context = [(GoodFaithIntrospection.GOOD_FAITH_INTROSPECTION_DISABLED): true]
ExecutionInput executionInput = ExecutionInput.newExecutionInput("query badActor{__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}}")
.graphQLContext(context).build()
ExecutionResult er = graphql.execute(executionInput)

then:
er.errors.isEmpty()

when:
context = [(GoodFaithIntrospection.GOOD_FAITH_INTROSPECTION_DISABLED): false]
executionInput = ExecutionInput.newExecutionInput("query badActor{__schema{types{fields{type{fields{type{fields{type{fields{type{name}}}}}}}}}}}")
.graphQLContext(context).build()
er = graphql.execute(executionInput)

then:
!er.errors.isEmpty()
er.errors[0] instanceof GoodFaithIntrospection.BadFaithIntrospectionError
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1387,17 +1387,10 @@ abstract class ExecutableNormalizedOperationToAstCompilerTest extends Specificat
'''
}

def "introspection query can be printed"() {
def "introspection query can be printed __schema"() {
def sdl = '''
type Query {
foo1: Foo
}
interface Foo {
test: String
}
type AFoo implements Foo {
test: String
aFoo: String
f: String
}
'''
def query = '''
Expand All @@ -1409,14 +1402,7 @@ abstract class ExecutableNormalizedOperationToAstCompilerTest extends Specificat
}
}
}

__type(name: "World") {
name
fields {
name
}
}
}
}
'''

GraphQLSchema schema = mkSchema(sdl)
Expand All @@ -1433,6 +1419,34 @@ abstract class ExecutableNormalizedOperationToAstCompilerTest extends Specificat
}
}
}
}
'''
}

def "introspection query can be printed __type"() {
def sdl = '''
type Query {
f: String
}
'''
def query = '''
query introspection_query {
__type(name: "World") {
name
fields {
name
}
}
}
'''

GraphQLSchema schema = mkSchema(sdl)
def fields = createNormalizedFields(schema, query)
when:
def result = localCompileToDocument(schema, QUERY, null, fields, noVariables)
def documentPrinted = AstPrinter.printAst(new AstSorter().sort(result.document))
then:
documentPrinted == '''{
__type(name: "World") {
fields {
name
Expand Down