diff --git a/src/main/java/graphql/execution/AsyncExecutionStrategy.java b/src/main/java/graphql/execution/AsyncExecutionStrategy.java index 3e640c9ace..30c0cd45dd 100644 --- a/src/main/java/graphql/execution/AsyncExecutionStrategy.java +++ b/src/main/java/graphql/execution/AsyncExecutionStrategy.java @@ -6,8 +6,10 @@ import graphql.execution.instrumentation.ExecutionStrategyInstrumentationContext; import graphql.execution.instrumentation.Instrumentation; import graphql.execution.instrumentation.parameters.InstrumentationExecutionStrategyParameters; +import graphql.introspection.Introspection; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.BiConsumer; @@ -46,6 +48,11 @@ public CompletableFuture execute(ExecutionContext executionCont MergedSelectionSet fields = parameters.getFields(); List fieldNames = fields.getKeys(); + Optional isNotSensible = Introspection.isIntrospectionSensible(executionContext.getGraphQLContext(),fields); + if (isNotSensible.isPresent()) { + return CompletableFuture.completedFuture(isNotSensible.get()); + } + DeferredExecutionSupport deferredExecutionSupport = createDeferredExecutionSupport(executionContext, parameters); Async.CombinedBuilder futures = getAsyncFieldValueInfo(executionContext, parameters, deferredExecutionSupport); diff --git a/src/main/java/graphql/execution/AsyncSerialExecutionStrategy.java b/src/main/java/graphql/execution/AsyncSerialExecutionStrategy.java index e5772f052d..3938c54d75 100644 --- a/src/main/java/graphql/execution/AsyncSerialExecutionStrategy.java +++ b/src/main/java/graphql/execution/AsyncSerialExecutionStrategy.java @@ -6,8 +6,10 @@ import graphql.execution.instrumentation.Instrumentation; import graphql.execution.instrumentation.InstrumentationContext; import graphql.execution.instrumentation.parameters.InstrumentationExecutionStrategyParameters; +import graphql.introspection.Introspection; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import static graphql.execution.instrumentation.SimpleInstrumentationContext.nonNullCtx; @@ -40,6 +42,13 @@ public CompletableFuture execute(ExecutionContext executionCont MergedSelectionSet fields = parameters.getFields(); ImmutableList fieldNames = ImmutableList.copyOf(fields.keySet()); + // 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 isNotSensible = Introspection.isIntrospectionSensible(executionContext.getGraphQLContext(), fields); + if (isNotSensible.isPresent()) { + return CompletableFuture.completedFuture(isNotSensible.get()); + } + CompletableFuture> resultsFuture = Async.eachSequentially(fieldNames, (fieldName, prevResults) -> { MergedField currentField = fields.getSubField(fieldName); ResultPath fieldPath = parameters.getPath().segment(mkNameForPath(currentField)); diff --git a/src/main/java/graphql/introspection/Introspection.java b/src/main/java/graphql/introspection/Introspection.java index d496e03702..4015ff393b 100644 --- a/src/main/java/graphql/introspection/Introspection.java +++ b/src/main/java/graphql/introspection/Introspection.java @@ -3,9 +3,12 @@ import com.google.common.collect.ImmutableSet; import graphql.Assert; +import graphql.ExecutionResult; import graphql.GraphQLContext; import graphql.Internal; import graphql.PublicApi; +import graphql.execution.MergedField; +import graphql.execution.MergedSelectionSet; import graphql.execution.ValuesResolver; import graphql.language.AstPrinter; import graphql.schema.FieldCoordinates; @@ -32,6 +35,7 @@ import graphql.schema.GraphQLSchema; import graphql.schema.GraphQLUnionType; import graphql.schema.InputValueWithState; +import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.HashSet; @@ -39,7 +43,9 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import java.util.stream.Collectors; @@ -58,8 +64,83 @@ import static graphql.schema.GraphQLTypeUtil.unwrapAllAs; import static graphql.schema.GraphQLTypeUtil.unwrapOne; +/** + * GraphQl has a unique capability called Introspection that allow + * consumers to inspect the system and discover the fields and types available and makes the system self documented. + *

+ * Some security recommendations such as OWASP + * recommend that introspection be disabled in production. The {@link Introspection#enabledJvmWide(boolean)} method can be used to disable + * introspection for the whole JVM or you can place {@link Introspection#INTROSPECTION_DISABLED} into the {@link GraphQLContext} of a request + * to disable introspection for that request. + */ @PublicApi public class Introspection { + + + /** + * Placing a boolean value under this key in the per request {@link GraphQLContext} will enable + * or disable Introspection on that request. + */ + public static final String INTROSPECTION_DISABLED = "INTROSPECTION_DISABLED"; + private static final AtomicBoolean INTROSPECTION_ENABLED_STATE = new AtomicBoolean(true); + + /** + * This static method will enable / disable Introspection at a JVM wide level. + * + * @param enabled the flag indicating the desired enabled state + * + * @return the previous state of enablement + */ + public static boolean enabledJvmWide(boolean enabled) { + return INTROSPECTION_ENABLED_STATE.getAndSet(enabled); + } + + /** + * @return true if Introspection is enabled at a JVM wide level or false otherwise + */ + public static boolean isEnabledJvmWide() { + return INTROSPECTION_ENABLED_STATE.get(); + } + + /** + * This will look in to the field selection set and see if there are introspection fields, + * and if there is,it checks if introspection should run, and if not it will return an errored {@link ExecutionResult} + * that can be returned to the user. + * + * @param mergedSelectionSet the fields to be executed + * + * @return an optional error result + */ + public static Optional isIntrospectionSensible(GraphQLContext graphQLContext, MergedSelectionSet mergedSelectionSet) { + MergedField schemaField = mergedSelectionSet.getSubField(SchemaMetaFieldDef.getName()); + if (schemaField != null) { + if (!isIntrospectionEnabled(graphQLContext)) { + return mkDisabledError(schemaField); + } + } + MergedField typeField = mergedSelectionSet.getSubField(TypeMetaFieldDef.getName()); + if (typeField != null) { + if (!isIntrospectionEnabled(graphQLContext)) { + return mkDisabledError(typeField); + } + } + // later we can put a good faith check code here to check the fields make sense + return Optional.empty(); + } + + @NotNull + private static Optional mkDisabledError(MergedField schemaField) { + IntrospectionDisabledError error = new IntrospectionDisabledError(schemaField.getSingleField().getSourceLocation()); + return Optional.of(ExecutionResult.newExecutionResult().addError(error).build()); + } + + private static boolean isIntrospectionEnabled(GraphQLContext graphQlContext) { + if (!isEnabledJvmWide()) { + return false; + } + return !graphQlContext.getOrDefault(INTROSPECTION_DISABLED, false); + } + private static final Map> introspectionDataFetchers = new LinkedHashMap<>(); private static void register(GraphQLFieldsContainer parentType, String fieldName, IntrospectionDataFetcher introspectionDataFetcher) { @@ -636,6 +717,7 @@ public enum DirectiveLocation { return environment.getGraphQLSchema().getType(name); }; + // __typename is always available public static final IntrospectionDataFetcher TypeNameMetaFieldDefDataFetcher = environment -> simplePrint(environment.getParentType()); @Internal diff --git a/src/main/java/graphql/introspection/IntrospectionDisabledError.java b/src/main/java/graphql/introspection/IntrospectionDisabledError.java new file mode 100644 index 0000000000..cbd7e077a3 --- /dev/null +++ b/src/main/java/graphql/introspection/IntrospectionDisabledError.java @@ -0,0 +1,35 @@ +package graphql.introspection; + +import graphql.ErrorClassification; +import graphql.ErrorType; +import graphql.GraphQLError; +import graphql.Internal; +import graphql.language.SourceLocation; + +import java.util.Collections; +import java.util.List; + +@Internal +public class IntrospectionDisabledError implements GraphQLError { + + private final List locations; + + public IntrospectionDisabledError(SourceLocation sourceLocation) { + locations = sourceLocation == null ? Collections.emptyList() : Collections.singletonList(sourceLocation); + } + + @Override + public String getMessage() { + return "Introspection has been disabled for this request"; + } + + @Override + public List getLocations() { + return locations; + } + + @Override + public ErrorClassification getErrorType() { + return ErrorClassification.errorClassification("IntrospectionDisabled"); + } +} diff --git a/src/main/java/graphql/schema/visibility/NoIntrospectionGraphqlFieldVisibility.java b/src/main/java/graphql/schema/visibility/NoIntrospectionGraphqlFieldVisibility.java index 604e794114..62aa16bbe0 100644 --- a/src/main/java/graphql/schema/visibility/NoIntrospectionGraphqlFieldVisibility.java +++ b/src/main/java/graphql/schema/visibility/NoIntrospectionGraphqlFieldVisibility.java @@ -12,10 +12,15 @@ * This field visibility will prevent Introspection queries from being performed. Technically this puts your * system in contravention of the specification * but some production systems want this lock down in place. + * + * @deprecated This is no longer the best way to prevent Introspection - {@link graphql.introspection.Introspection#enabledJvmWide(boolean)} + * can be used instead */ @PublicApi +@Deprecated(since = "2024-03-16") public class NoIntrospectionGraphqlFieldVisibility implements GraphqlFieldVisibility { + @Deprecated(since = "2024-03-16") public static NoIntrospectionGraphqlFieldVisibility NO_INTROSPECTION_FIELD_VISIBILITY = new NoIntrospectionGraphqlFieldVisibility(); diff --git a/src/test/groovy/graphql/introspection/IntrospectionTest.groovy b/src/test/groovy/graphql/introspection/IntrospectionTest.groovy index 62e27138d1..8a70c68618 100644 --- a/src/test/groovy/graphql/introspection/IntrospectionTest.groovy +++ b/src/test/groovy/graphql/introspection/IntrospectionTest.groovy @@ -1,7 +1,8 @@ package graphql.introspection - +import graphql.ExecutionInput import graphql.TestUtil +import graphql.execution.AsyncSerialExecutionStrategy import graphql.schema.DataFetcher import graphql.schema.FieldCoordinates import graphql.schema.GraphQLCodeRegistry @@ -22,6 +23,14 @@ import static graphql.schema.GraphQLSchema.newSchema class IntrospectionTest extends Specification { + def setup() { + Introspection.enabledJvmWide(true) + } + + def cleanup() { + Introspection.enabledJvmWide(true) + } + def "bug 1186 - introspection depth check"() { def spec = ''' type Query { @@ -547,7 +556,7 @@ class IntrospectionTest extends Specification { then: def oldQuery = oldIntrospectionQuery.replaceAll("\\s+", "") - def newQuery = newIntrospectionQuery.replaceAll("\\s+","") + def newQuery = newIntrospectionQuery.replaceAll("\\s+", "") oldQuery == newQuery } @@ -688,4 +697,111 @@ class IntrospectionTest extends Specification { queryType["isOneOf"] == null } + def "jvm wide enablement"() { + def graphQL = TestUtil.graphQL("type Query { f : String } ").build() + + when: + def er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors.isEmpty() + + when: + Introspection.enabledJvmWide(false) + er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors[0] instanceof IntrospectionDisabledError + er.errors[0].getErrorType().toString() == "IntrospectionDisabled" + + when: + Introspection.enabledJvmWide(true) + er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors.isEmpty() + } + + def "per request enablement"() { + def graphQL = TestUtil.graphQL("type Query { f : String } ").build() + + when: + // null context + def ei = ExecutionInput.newExecutionInput(IntrospectionQuery.INTROSPECTION_QUERY) + .build() + def er = graphQL.execute(ei) + + then: + er.errors.isEmpty() + + when: + ei = ExecutionInput.newExecutionInput(IntrospectionQuery.INTROSPECTION_QUERY) + .graphQLContext(Map.of(Introspection.INTROSPECTION_DISABLED, false)).build() + er = graphQL.execute(ei) + + then: + er.errors.isEmpty() + + when: + ei = ExecutionInput.newExecutionInput(IntrospectionQuery.INTROSPECTION_QUERY) + .graphQLContext(Map.of(Introspection.INTROSPECTION_DISABLED, true)).build() + er = graphQL.execute(ei) + + then: + er.errors[0] instanceof IntrospectionDisabledError + er.errors[0].getErrorType().toString() == "IntrospectionDisabled" + } + + def "mixed schema and other fields stop early"() { + def graphQL = TestUtil.graphQL("type Query { normalField : String } ").build() + + def query = """ + query goodAndBad { + normalField + __schema{ types{ fields { name }}} + } + """ + + when: + def er = graphQL.execute(query) + + then: + er.errors.isEmpty() + + when: + Introspection.enabledJvmWide(false) + er = graphQL.execute(query) + + then: + er.errors[0] instanceof IntrospectionDisabledError + er.errors[0].getErrorType().toString() == "IntrospectionDisabled" + er.data == null // stops hard + } + + def "AsyncSerialExecutionStrategy with jvm wide enablement"() { + def graphQL = TestUtil.graphQL("type Query { f : String } ") + .queryExecutionStrategy(new AsyncSerialExecutionStrategy()).build() + + when: + def er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors.isEmpty() + + when: + Introspection.enabledJvmWide(false) + er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors[0] instanceof IntrospectionDisabledError + er.errors[0].getErrorType().toString() == "IntrospectionDisabled" + + when: + Introspection.enabledJvmWide(true) + er = graphQL.execute(IntrospectionQuery.INTROSPECTION_QUERY) + + then: + er.errors.isEmpty() + } + }