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
8 changes: 8 additions & 0 deletions src/main/java/graphql/execution/AsyncExecutionStrategy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -44,6 +46,12 @@ public CompletableFuture<ExecutionResult> execute(ExecutionContext executionCont

MergedSelectionSet fields = parameters.getFields();
List<String> fieldNames = fields.getKeys();

Optional<ExecutionResult> isNotSensible = Introspection.isIntrospectionSensible(fields, executionContext);
if (isNotSensible.isPresent()) {
return CompletableFuture.completedFuture(isNotSensible.get());
}

Async.CombinedBuilder<FieldValueInfo> futures = Async.ofExpectedSize(fields.size());
for (String fieldName : fieldNames) {
MergedField currentField = fields.getSubField(fieldName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,6 +41,13 @@ public CompletableFuture<ExecutionResult> execute(ExecutionContext executionCont
MergedSelectionSet fields = parameters.getFields();
ImmutableList<String> 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<ExecutionResult> isNotSensible = Introspection.isIntrospectionSensible(fields, executionContext);
if (isNotSensible.isPresent()) {
return CompletableFuture.completedFuture(isNotSensible.get());
}

CompletableFuture<List<ExecutionResult>> resultsFuture = Async.eachSequentially(fieldNames, (fieldName, prevResults) -> {
MergedField currentField = fields.getSubField(fieldName);
ResultPath fieldPath = parameters.getPath().segment(mkNameForPath(currentField));
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;
}
}
}
88 changes: 88 additions & 0 deletions src/main/java/graphql/introspection/Introspection.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,14 +36,17 @@
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;
import java.util.LinkedHashMap;
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;

Expand All @@ -58,8 +65,88 @@
import static graphql.schema.GraphQLTypeUtil.unwrapAllAs;
import static graphql.schema.GraphQLTypeUtil.unwrapOne;

/**
* GraphQl has a unique capability called <a href="https://spec.graphql.org/October2021/#sec-Introspection">Introspection</a> that allow
* consumers to inspect the system and discover the fields and types available and makes the system self documented.
* <p>
* Some security recommendations such as <a href="https://owasp.org/www-chapter-vancouver/assets/presentations/2020-06_GraphQL_Security.pdf">OWASP</a>
* 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<ExecutionResult> 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<ExecutionResult> 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<FieldCoordinates, IntrospectionDataFetcher<?>> introspectionDataFetchers = new LinkedHashMap<>();

private static void register(GraphQLFieldsContainer parentType, String fieldName, IntrospectionDataFetcher<?> introspectionDataFetcher) {
Expand Down Expand Up @@ -623,6 +710,7 @@ public enum DirectiveLocation {
return environment.getGraphQLSchema().getType(name);
};

// __typename is always available
public static final IntrospectionDataFetcher<?> TypeNameMetaFieldDefDataFetcher = environment -> simplePrint(environment.getParentType());

@Internal
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SourceLocation> 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<SourceLocation> getLocations() {
return locations;
}

@Override
public ErrorClassification getErrorType() {
return ErrorClassification.errorClassification("IntrospectionDisabled");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@
* This field visibility will prevent Introspection queries from being performed. Technically this puts your
* system in contravention of <a href="https://spec.graphql.org/October2021/#sec-Introspection">the specification</a>
* 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();


Expand Down
Loading