diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index b361a48d8f..1633f5bdca 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - master + - 21.x - 20.x - 19.x - 18.x diff --git a/src/main/java/graphql/execution/AsyncExecutionStrategy.java b/src/main/java/graphql/execution/AsyncExecutionStrategy.java index 6608fba0f3..2ca2e08942 100644 --- a/src/main/java/graphql/execution/AsyncExecutionStrategy.java +++ b/src/main/java/graphql/execution/AsyncExecutionStrategy.java @@ -5,8 +5,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; @@ -44,6 +46,12 @@ public CompletableFuture execute(ExecutionContext executionCont MergedSelectionSet fields = parameters.getFields(); List fieldNames = fields.getKeys(); + + Optional isNotSensible = Introspection.isIntrospectionSensible(fields, executionContext); + if (isNotSensible.isPresent()) { + return CompletableFuture.completedFuture(isNotSensible.get()); + } + Async.CombinedBuilder futures = Async.ofExpectedSize(fields.size()); for (String fieldName : fieldNames) { MergedField currentField = fields.getSubField(fieldName); diff --git a/src/main/java/graphql/execution/AsyncSerialExecutionStrategy.java b/src/main/java/graphql/execution/AsyncSerialExecutionStrategy.java index fc2dde0980..06ddc4dca2 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; @@ -39,6 +41,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(fields, executionContext); + 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/execution/Execution.java b/src/main/java/graphql/execution/Execution.java index 916bc64659..4014cd71f8 100644 --- a/src/main/java/graphql/execution/Execution.java +++ b/src/main/java/graphql/execution/Execution.java @@ -95,6 +95,7 @@ public CompletableFuture execute(Document document, GraphQLSche .executionInput(executionInput) .build(); + executionContext.getGraphQLContext().put(ResultNodesInfo.RESULT_NODES_INFO, executionContext.getResultNodesInfo()); InstrumentationExecutionParameters parameters = new InstrumentationExecutionParameters( executionInput, graphQLSchema, instrumentationState diff --git a/src/main/java/graphql/execution/ExecutionContext.java b/src/main/java/graphql/execution/ExecutionContext.java index 50db89b566..141954205b 100644 --- a/src/main/java/graphql/execution/ExecutionContext.java +++ b/src/main/java/graphql/execution/ExecutionContext.java @@ -57,6 +57,7 @@ public class ExecutionContext { private final ValueUnboxer valueUnboxer; private final ExecutionInput executionInput; private final Supplier queryTree; + private final ResultNodesInfo resultNodesInfo = new ResultNodesInfo(); ExecutionContext(ExecutionContextBuilder builder) { this.graphQLSchema = builder.graphQLSchema; @@ -285,4 +286,8 @@ public ExecutionContext transform(Consumer builderConsu builderConsumer.accept(builder); return builder.build(); } + + public ResultNodesInfo getResultNodesInfo() { + return resultNodesInfo; + } } diff --git a/src/main/java/graphql/execution/ExecutionStrategy.java b/src/main/java/graphql/execution/ExecutionStrategy.java index 4ed0f1e644..73d5000677 100644 --- a/src/main/java/graphql/execution/ExecutionStrategy.java +++ b/src/main/java/graphql/execution/ExecutionStrategy.java @@ -62,6 +62,7 @@ import static graphql.execution.FieldValueInfo.CompleteValueType.NULL; import static graphql.execution.FieldValueInfo.CompleteValueType.OBJECT; import static graphql.execution.FieldValueInfo.CompleteValueType.SCALAR; +import static graphql.execution.ResultNodesInfo.MAX_RESULT_NODES; import static graphql.execution.instrumentation.SimpleInstrumentationContext.nonNullCtx; import static graphql.schema.DataFetchingEnvironmentImpl.newDataFetchingEnvironment; import static graphql.schema.GraphQLTypeUtil.isEnum; @@ -239,7 +240,23 @@ protected CompletableFuture fetchField(ExecutionContext executionC MergedField field = parameters.getField(); GraphQLObjectType parentType = (GraphQLObjectType) parameters.getExecutionStepInfo().getUnwrappedNonNullType(); GraphQLFieldDefinition fieldDef = getFieldDef(executionContext.getGraphQLSchema(), parentType, field.getSingleField()); - GraphQLCodeRegistry codeRegistry = executionContext.getGraphQLSchema().getCodeRegistry(); + return fetchField(fieldDef, executionContext, parameters); + } + + private CompletableFuture fetchField(GraphQLFieldDefinition fieldDef, ExecutionContext executionContext, ExecutionStrategyParameters parameters) { + + int resultNodesCount = executionContext.getResultNodesInfo().incrementAndGetResultNodesCount(); + + Integer maxNodes; + if ((maxNodes = executionContext.getGraphQLContext().get(MAX_RESULT_NODES)) != null) { + if (resultNodesCount > maxNodes) { + executionContext.getResultNodesInfo().maxResultNodesExceeded(); + return CompletableFuture.completedFuture(new FetchedValue(null, null, ImmutableKit.emptyList(), null)); + } + } + + MergedField field = parameters.getField(); + GraphQLObjectType parentType = (GraphQLObjectType) parameters.getExecutionStepInfo().getUnwrappedNonNullType(); // if the DF (like PropertyDataFetcher) does not use the arguments or execution step info then dont build any @@ -274,6 +291,7 @@ protected CompletableFuture fetchField(ExecutionContext executionC .queryDirectives(queryDirectives) .build(); }); + GraphQLCodeRegistry codeRegistry = executionContext.getGraphQLSchema().getCodeRegistry(); DataFetcher dataFetcher = codeRegistry.getDataFetcher(parentType, fieldDef); Instrumentation instrumentation = executionContext.getInstrumentation(); @@ -568,6 +586,15 @@ protected FieldValueInfo completeValueForList(ExecutionContext executionContext, List fieldValueInfos = new ArrayList<>(size.orElse(1)); int index = 0; for (Object item : iterableValues) { + int resultNodesCount = executionContext.getResultNodesInfo().incrementAndGetResultNodesCount(); + Integer maxNodes; + if ((maxNodes = executionContext.getGraphQLContext().get(MAX_RESULT_NODES)) != null) { + if (resultNodesCount > maxNodes) { + executionContext.getResultNodesInfo().maxResultNodesExceeded(); + return new FieldValueInfo(NULL, completedFuture(ExecutionResult.newExecutionResult().build()), fieldValueInfos); + } + } + ResultPath indexedPath = parameters.getPath().segment(index); ExecutionStepInfo stepInfoForListElement = executionStepInfoFactory.newExecutionStepInfoForListElement(executionStepInfo, indexedPath); diff --git a/src/main/java/graphql/execution/FetchedValue.java b/src/main/java/graphql/execution/FetchedValue.java index 28d2ce6da6..8cc520cb28 100644 --- a/src/main/java/graphql/execution/FetchedValue.java +++ b/src/main/java/graphql/execution/FetchedValue.java @@ -19,7 +19,7 @@ public class FetchedValue { private final Object localContext; private final ImmutableList errors; - private FetchedValue(Object fetchedValue, Object rawFetchedValue, ImmutableList errors, Object localContext) { + FetchedValue(Object fetchedValue, Object rawFetchedValue, ImmutableList errors, Object localContext) { this.fetchedValue = fetchedValue; this.rawFetchedValue = rawFetchedValue; this.errors = errors; diff --git a/src/main/java/graphql/execution/FieldValueInfo.java b/src/main/java/graphql/execution/FieldValueInfo.java index 168ffab735..c889e98b34 100644 --- a/src/main/java/graphql/execution/FieldValueInfo.java +++ b/src/main/java/graphql/execution/FieldValueInfo.java @@ -25,7 +25,7 @@ public enum CompleteValueType { private final CompletableFuture fieldValue; private final List fieldValueInfos; - private FieldValueInfo(CompleteValueType completeValueType, CompletableFuture fieldValue, List fieldValueInfos) { + FieldValueInfo(CompleteValueType completeValueType, CompletableFuture fieldValue, List fieldValueInfos) { assertNotNull(fieldValueInfos, () -> "fieldValueInfos can't be null"); this.completeValueType = completeValueType; this.fieldValue = fieldValue; diff --git a/src/main/java/graphql/execution/ResultNodesInfo.java b/src/main/java/graphql/execution/ResultNodesInfo.java new file mode 100644 index 0000000000..afc366f6be --- /dev/null +++ b/src/main/java/graphql/execution/ResultNodesInfo.java @@ -0,0 +1,55 @@ +package graphql.execution; + +import graphql.Internal; +import graphql.PublicApi; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * This class is used to track the number of result nodes that have been created during execution. + * After each execution the GraphQLContext contains a ResultNodeInfo object under the key {@link ResultNodesInfo#RESULT_NODES_INFO} + *

+ * The number of result can be limited (and should be for security reasons) by setting the maximum number of result nodes + * in the GraphQLContext under the key {@link ResultNodesInfo#MAX_RESULT_NODES} to an Integer + *

+ */ +@PublicApi +public class ResultNodesInfo { + + public static final String MAX_RESULT_NODES = "__MAX_RESULT_NODES"; + public static final String RESULT_NODES_INFO = "__RESULT_NODES_INFO"; + + private volatile boolean maxResultNodesExceeded = false; + private final AtomicInteger resultNodesCount = new AtomicInteger(0); + + @Internal + public int incrementAndGetResultNodesCount() { + return resultNodesCount.incrementAndGet(); + } + + @Internal + public void maxResultNodesExceeded() { + this.maxResultNodesExceeded = true; + } + + /** + * The number of result nodes created. + * Note: this can be higher than max result nodes because + * a each node that exceeds the number of max nodes is set to null, + * but still is a result node (with value null) + * + * @return number of result nodes created + */ + public int getResultNodesCount() { + return resultNodesCount.get(); + } + + /** + * If the number of result nodes has exceeded the maximum allowed numbers. + * + * @return true if the number of result nodes has exceeded the maximum allowed numbers + */ + public boolean isMaxResultNodesExceeded() { + return maxResultNodesExceeded; + } +} diff --git a/src/main/java/graphql/introspection/GoodFaithIntrospection.java b/src/main/java/graphql/introspection/GoodFaithIntrospection.java new file mode 100644 index 0000000000..7e853016ac --- /dev/null +++ b/src/main/java/graphql/introspection/GoodFaithIntrospection.java @@ -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. + *

+ * 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. + *

+ * 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". + *

+ * 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 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 checkIntrospection(ExecutionContext executionContext) { + if (isIntrospectionEnabled(executionContext.getGraphQLContext())) { + ExecutableNormalizedOperation operation = executionContext.getNormalizedQueryTree().get(); + ImmutableListMultimap coordinatesToENFs = operation.getCoordinatesToNormalizedFields(); + for (Map.Entry entry : ALLOWED_FIELD_INSTANCES.entrySet()) { + FieldCoordinates coordinates = entry.getKey(); + Integer allowSize = entry.getValue(); + ImmutableList 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 getLocations() { + return null; + } + } +} diff --git a/src/main/java/graphql/introspection/Introspection.java b/src/main/java/graphql/introspection/Introspection.java index d496e03702..c228f1c39f 100644 --- a/src/main/java/graphql/introspection/Introspection.java +++ b/src/main/java/graphql/introspection/Introspection.java @@ -3,9 +3,13 @@ import com.google.common.collect.ImmutableSet; import graphql.Assert; +import graphql.ExecutionResult; 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; import graphql.language.AstPrinter; import graphql.schema.FieldCoordinates; @@ -32,6 +36,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 +44,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 +65,88 @@ 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 + * @param executionContext the execution context in play + * + * @return an optional error result + */ + public static Optional isIntrospectionSensible(MergedSelectionSet mergedSelectionSet, ExecutionContext executionContext) { + GraphQLContext graphQLContext = executionContext.getGraphQLContext(); + 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); + } + } + if (schemaField != null || typeField != null) + { + return GoodFaithIntrospection.checkIntrospection(executionContext); + } + 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 +723,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/GraphQLTest.groovy b/src/test/groovy/graphql/GraphQLTest.groovy index ec2523d3f8..d994ad9a0f 100644 --- a/src/test/groovy/graphql/GraphQLTest.groovy +++ b/src/test/groovy/graphql/GraphQLTest.groovy @@ -13,6 +13,7 @@ import graphql.execution.ExecutionId import graphql.execution.ExecutionIdProvider import graphql.execution.ExecutionStrategyParameters import graphql.execution.MissingRootTypeException +import graphql.execution.ResultNodesInfo import graphql.execution.SubscriptionExecutionStrategy import graphql.execution.ValueUnboxer import graphql.execution.instrumentation.ChainedInstrumentation @@ -49,6 +50,7 @@ import static graphql.ExecutionInput.Builder import static graphql.ExecutionInput.newExecutionInput import static graphql.Scalars.GraphQLInt import static graphql.Scalars.GraphQLString +import static graphql.execution.ResultNodesInfo.MAX_RESULT_NODES import static graphql.schema.GraphQLArgument.newArgument import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition import static graphql.schema.GraphQLInputObjectField.newInputObjectField @@ -1440,4 +1442,143 @@ many lines'''] then: !er.errors.isEmpty() } + + def "max result nodes not breached"() { + given: + def sdl = ''' + + type Query { + hello: String + } + ''' + def df = { env -> "world" } as DataFetcher + def fetchers = ["Query": ["hello": df]] + def schema = TestUtil.schema(sdl, fetchers) + def graphQL = GraphQL.newGraphQL(schema).build() + + def query = "{ hello h1: hello h2: hello h3: hello } " + def ei = newExecutionInput(query).build() + ei.getGraphQLContext().put(MAX_RESULT_NODES, 4); + + when: + def er = graphQL.execute(ei) + def rni = ei.getGraphQLContext().get(ResultNodesInfo.RESULT_NODES_INFO) as ResultNodesInfo + then: + !rni.maxResultNodesExceeded + rni.resultNodesCount == 4 + er.data == [hello: "world", h1: "world", h2: "world", h3: "world"] + } + + def "max result nodes breached"() { + given: + def sdl = ''' + + type Query { + hello: String + } + ''' + def df = { env -> "world" } as DataFetcher + def fetchers = ["Query": ["hello": df]] + def schema = TestUtil.schema(sdl, fetchers) + def graphQL = GraphQL.newGraphQL(schema).build() + + def query = "{ hello h1: hello h2: hello h3: hello } " + def ei = newExecutionInput(query).build() + ei.getGraphQLContext().put(MAX_RESULT_NODES, 3); + + when: + def er = graphQL.execute(ei) + def rni = ei.getGraphQLContext().get(ResultNodesInfo.RESULT_NODES_INFO) as ResultNodesInfo + then: + rni.maxResultNodesExceeded + rni.resultNodesCount == 4 + er.data == [hello: "world", h1: "world", h2: "world", h3: null] + } + + def "max result nodes breached with list"() { + given: + def sdl = ''' + + type Query { + hello: [String] + } + ''' + def df = { env -> ["w1", "w2", "w3"] } as DataFetcher + def fetchers = ["Query": ["hello": df]] + def schema = TestUtil.schema(sdl, fetchers) + def graphQL = GraphQL.newGraphQL(schema).build() + + def query = "{ hello}" + def ei = newExecutionInput(query).build() + ei.getGraphQLContext().put(MAX_RESULT_NODES, 3); + + when: + def er = graphQL.execute(ei) + def rni = ei.getGraphQLContext().get(ResultNodesInfo.RESULT_NODES_INFO) as ResultNodesInfo + then: + rni.maxResultNodesExceeded + rni.resultNodesCount == 4 + er.data == [hello: null] + } + + def "max result nodes breached with list 2"() { + given: + def sdl = ''' + + type Query { + hello: [Foo] + } + type Foo { + name: String + } + ''' + def df = { env -> [[name: "w1"], [name: "w2"], [name: "w3"]] } as DataFetcher + def fetchers = ["Query": ["hello": df]] + def schema = TestUtil.schema(sdl, fetchers) + def graphQL = GraphQL.newGraphQL(schema).build() + + def query = "{ hello {name}}" + def ei = newExecutionInput(query).build() + // we have 7 result nodes overall + ei.getGraphQLContext().put(MAX_RESULT_NODES, 6); + + when: + def er = graphQL.execute(ei) + def rni = ei.getGraphQLContext().get(ResultNodesInfo.RESULT_NODES_INFO) as ResultNodesInfo + then: + rni.resultNodesCount == 7 + rni.maxResultNodesExceeded + er.data == [hello: [[name: "w1"], [name: "w2"], [name: null]]] + } + + def "max result nodes not breached with list"() { + given: + def sdl = ''' + + type Query { + hello: [Foo] + } + type Foo { + name: String + } + ''' + def df = { env -> [[name: "w1"], [name: "w2"], [name: "w3"]] } as DataFetcher + def fetchers = ["Query": ["hello": df]] + def schema = TestUtil.schema(sdl, fetchers) + def graphQL = GraphQL.newGraphQL(schema).build() + + def query = "{ hello {name}}" + def ei = newExecutionInput(query).build() + // we have 7 result nodes overall + ei.getGraphQLContext().put(MAX_RESULT_NODES, 7); + + when: + def er = graphQL.execute(ei) + def rni = ei.getGraphQLContext().get(ResultNodesInfo.RESULT_NODES_INFO) as ResultNodesInfo + then: + !rni.maxResultNodesExceeded + rni.resultNodesCount == 7 + er.data == [hello: [[name: "w1"], [name: "w2"], [name: "w3"]]] + } + } diff --git a/src/test/groovy/graphql/introspection/GoodFaithIntrospectionInstrumentationTest.groovy b/src/test/groovy/graphql/introspection/GoodFaithIntrospectionInstrumentationTest.groovy new file mode 100644 index 0000000000..f1ffc2c570 --- /dev/null +++ b/src/test/groovy/graphql/introspection/GoodFaithIntrospectionInstrumentationTest.groovy @@ -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 + } +} diff --git a/src/test/groovy/graphql/introspection/IntrospectionTest.groovy b/src/test/groovy/graphql/introspection/IntrospectionTest.groovy index 62e27138d1..cc5435f588 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 { @@ -544,7 +553,6 @@ class IntrospectionTest extends Specification { def newIntrospectionQuery = IntrospectionQuery.INTROSPECTION_QUERY - then: def oldQuery = oldIntrospectionQuery.replaceAll("\\s+", "") def newQuery = newIntrospectionQuery.replaceAll("\\s+","") @@ -648,6 +656,112 @@ class IntrospectionTest extends Specification { arg['defaultValue'] == "null" // printed AST } + 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() + } def "introspection for oneOf support"() { def spec = ''' diff --git a/src/test/groovy/graphql/normalized/ExecutableNormalizedOperationToAstCompilerTest.groovy b/src/test/groovy/graphql/normalized/ExecutableNormalizedOperationToAstCompilerTest.groovy index dd074ce7a8..cae75b34ca 100644 --- a/src/test/groovy/graphql/normalized/ExecutableNormalizedOperationToAstCompilerTest.groovy +++ b/src/test/groovy/graphql/normalized/ExecutableNormalizedOperationToAstCompilerTest.groovy @@ -1383,17 +1383,10 @@ class ExecutableNormalizedOperationToAstCompilerTest extends Specification { ''' } - 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 = ''' @@ -1405,14 +1398,7 @@ class ExecutableNormalizedOperationToAstCompilerTest extends Specification { } } } - - __type(name: "World") { - name - fields { - name - } - } - } + } ''' GraphQLSchema schema = mkSchema(sdl) @@ -1429,6 +1415,34 @@ class ExecutableNormalizedOperationToAstCompilerTest extends Specification { } } } +} +''' + } + + 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 = compileToDocument(schema, QUERY, null, fields, noVariables) + def documentPrinted = AstPrinter.printAst(new AstSorter().sort(result.document)) + then: + documentPrinted == '''{ __type(name: "World") { fields { name