Skip to content
Open
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
36 changes: 34 additions & 2 deletions src/main/java/graphql/GraphQL.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@
import graphql.execution.preparsed.NoOpPreparsedDocumentProvider;
import graphql.execution.preparsed.PreparsedDocumentEntry;
import graphql.execution.preparsed.PreparsedDocumentProvider;
import graphql.introspection.GoodFaithIntrospection;
import graphql.language.Document;
import graphql.schema.GraphQLSchema;
import graphql.validation.GoodFaithIntrospectionExceeded;
import graphql.validation.OperationValidationRule;
import graphql.validation.QueryComplexityLimits;
import graphql.validation.ValidationError;
import graphql.validation.ValidationErrorType;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.NullUnmarked;

Expand Down Expand Up @@ -567,7 +571,12 @@ private PreparsedDocumentEntry parseAndValidate(AtomicReference<ExecutionInput>
executionInput = executionInput.transform(builder -> builder.variables(parseResult.getVariables()));
executionInputRef.set(executionInput);

final List<ValidationError> errors = validate(executionInput, assertNotNull(document, "Document cannot be null when parse succeeded"), graphQLSchema, instrumentationState);
final List<ValidationError> errors;
try {
errors = validate(executionInput, assertNotNull(document, "Document cannot be null when parse succeeded"), graphQLSchema, instrumentationState);
} catch (GoodFaithIntrospectionExceeded e) {
return new PreparsedDocumentEntry(document, List.of(e.toBadFaithError()));
}
if (!errors.isEmpty()) {
return new PreparsedDocumentEntry(document, errors);
}
Expand Down Expand Up @@ -601,7 +610,30 @@ private List<ValidationError> validate(ExecutionInput executionInput, Document d

Predicate<OperationValidationRule> validationRulePredicate = executionInput.getGraphQLContext().getOrDefault(ParseAndValidate.INTERNAL_VALIDATION_PREDICATE_HINT, r -> true);
Locale locale = executionInput.getLocale() != null ? executionInput.getLocale() : Locale.getDefault();
List<ValidationError> validationErrors = ParseAndValidate.validate(graphQLSchema, document, validationRulePredicate, locale);
QueryComplexityLimits limits = executionInput.getGraphQLContext().get(QueryComplexityLimits.KEY);

// Good Faith Introspection: apply tighter limits and enable the rule for introspection queries
boolean goodFaithActive = GoodFaithIntrospection.isEnabled(executionInput.getGraphQLContext())
&& GoodFaithIntrospection.containsIntrospectionFields(document);
if (goodFaithActive) {
limits = GoodFaithIntrospection.goodFaithLimits(limits);
} else {
Predicate<OperationValidationRule> existing = validationRulePredicate;
validationRulePredicate = rule -> rule != OperationValidationRule.GOOD_FAITH_INTROSPECTION && existing.test(rule);
}

List<ValidationError> validationErrors = ParseAndValidate.validate(graphQLSchema, document, validationRulePredicate, locale, limits);

// If good faith is active and a complexity limit error was produced, convert it to a bad faith error
if (goodFaithActive) {
for (ValidationError error : validationErrors) {
if (error.getValidationErrorType() == ValidationErrorType.MaxQueryFieldsExceeded
|| error.getValidationErrorType() == ValidationErrorType.MaxQueryDepthExceeded) {
validationCtx.onCompleted(null, null);
throw GoodFaithIntrospectionExceeded.tooBigOperation(error.getDescription());
}
}
}

validationCtx.onCompleted(validationErrors, null);
return validationErrors;
Expand Down
19 changes: 18 additions & 1 deletion src/main/java/graphql/ParseAndValidate.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
import graphql.parser.ParserOptions;
import graphql.schema.GraphQLSchema;
import graphql.validation.OperationValidationRule;
import graphql.validation.QueryComplexityLimits;
import graphql.validation.ValidationError;
import graphql.validation.Validator;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

import java.util.List;
import java.util.Locale;
Expand Down Expand Up @@ -118,8 +120,23 @@ public static List<ValidationError> validate(@NonNull GraphQLSchema graphQLSchem
* @return a result object that indicates how this operation went
*/
public static List<ValidationError> validate(@NonNull GraphQLSchema graphQLSchema, @NonNull Document parsedDocument, @NonNull Predicate<OperationValidationRule> rulePredicate, @NonNull Locale locale) {
return validate(graphQLSchema, parsedDocument, rulePredicate, locale, null);
}

/**
* This can be called to validate a parsed graphql query.
*
* @param graphQLSchema the graphql schema to validate against
* @param parsedDocument the previously parsed document
* @param rulePredicate this predicate is used to decide what validation rules will be applied
* @param locale the current locale
* @param limits optional query complexity limits to enforce
*
* @return a result object that indicates how this operation went
*/
public static List<ValidationError> validate(@NonNull GraphQLSchema graphQLSchema, @NonNull Document parsedDocument, @NonNull Predicate<OperationValidationRule> rulePredicate, @NonNull Locale locale, @Nullable QueryComplexityLimits limits) {
Validator validator = new Validator();
return validator.validateDocument(graphQLSchema, parsedDocument, rulePredicate, locale);
return validator.validateDocument(graphQLSchema, parsedDocument, rulePredicate, locale, limits);
}

/**
Expand Down
142 changes: 71 additions & 71 deletions src/main/java/graphql/introspection/GoodFaithIntrospection.java
Original file line number Diff line number Diff line change
@@ -1,43 +1,44 @@
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.AbortExecutionException;
import graphql.execution.ExecutionContext;
import graphql.language.Definition;
import graphql.language.Document;
import graphql.language.Field;
import graphql.language.OperationDefinition;
import graphql.language.Selection;
import graphql.language.SelectionSet;
import graphql.language.SourceLocation;
import graphql.normalized.ExecutableNormalizedField;
import graphql.normalized.ExecutableNormalizedOperation;
import graphql.schema.FieldCoordinates;
import graphql.validation.QueryComplexityLimits;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

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

import static graphql.normalized.ExecutableNormalizedOperationFactory.Options;
import static graphql.normalized.ExecutableNormalizedOperationFactory.createExecutableNormalizedOperation;
import static graphql.schema.FieldCoordinates.coordinates;

/**
* This {@link graphql.execution.instrumentation.Instrumentation} ensure that a submitted introspection query is done in
* good faith.
* Good Faith Introspection ensures that introspection queries are not abused to cause denial of service.
* <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.
* When enabled, the validation layer enforces that:
* <ul>
* <li>Only one {@code __schema} and one {@code __type} field can appear per operation</li>
* <li>The {@code __Type} fields {@code fields}, {@code inputFields}, {@code interfaces}, and {@code possibleTypes}
* can each only appear once (preventing cyclic traversals)</li>
* <li>The query complexity is limited to {@link #GOOD_FAITH_MAX_FIELDS_COUNT} fields and
* {@link #GOOD_FAITH_MAX_DEPTH_COUNT} depth</li>
* </ul>
* This allows the standard and common introspection queries to work so tooling such as graphiql can work.
*/
@PublicApi
@NullMarked
public class GoodFaithIntrospection {

/**
Expand Down Expand Up @@ -74,67 +75,66 @@ 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;
try {
operation = mkOperation(executionContext);
} catch (AbortExecutionException e) {
BadFaithIntrospectionError error = BadFaithIntrospectionError.tooBigOperation(e.getMessage());
return Optional.of(ExecutionResult.newExecutionResult().addError(error).build());
}
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 = BadFaithIntrospectionError.tooManyFields(coordinates.toString());
return Optional.of(ExecutionResult.newExecutionResult().addError(error).build());
}
}
/**
* Checks whether Good Faith Introspection is enabled for the given request context.
*
* @param graphQLContext the per-request context
*
* @return true if good faith introspection checks should be applied
*/
public static boolean isEnabled(GraphQLContext graphQLContext) {
if (!isEnabledJvmWide()) {
return false;
}
return Optional.empty();
return !graphQLContext.getBoolean(GOOD_FAITH_INTROSPECTION_DISABLED, false);
}

/**
* This makes an executable operation limited in size then which suits a good faith introspection query. This helps guard
* against malicious queries.
* Performs a shallow scan of the document to check if any operation's top-level selections
* contain introspection fields ({@code __schema} or {@code __type}).
*
* @param executionContext the execution context
* @param document the parsed document
*
* @return an executable operation
* @return true if the document contains top-level introspection fields
*/
private static ExecutableNormalizedOperation mkOperation(ExecutionContext executionContext) throws AbortExecutionException {
Options options = Options.defaultOptions()
.maxFieldsCount(GOOD_FAITH_MAX_FIELDS_COUNT)
.maxChildrenDepth(GOOD_FAITH_MAX_DEPTH_COUNT)
.locale(executionContext.getLocale())
.graphQLContext(executionContext.getGraphQLContext());

return createExecutableNormalizedOperation(executionContext.getGraphQLSchema(),
executionContext.getOperationDefinition(),
executionContext.getFragmentsByName(),
executionContext.getCoercedVariables(),
options);

public static boolean containsIntrospectionFields(Document document) {
for (Definition<?> definition : document.getDefinitions()) {
if (definition instanceof OperationDefinition) {
SelectionSet selectionSet = ((OperationDefinition) definition).getSelectionSet();
if (selectionSet != null) {
for (Selection<?> selection : selectionSet.getSelections()) {
if (selection instanceof Field) {
String name = ((Field) selection).getName();
if ("__schema".equals(name) || "__type".equals(name)) {
return true;
}
}
}
}
}
}
return false;
}

private static boolean isIntrospectionEnabled(GraphQLContext graphQlContext) {
if (!isEnabledJvmWide()) {
return false;
/**
* Returns query complexity limits that are the minimum of the existing limits and the
* good faith introspection limits. This ensures introspection queries are bounded
* without overriding tighter user-specified limits.
*
* @param existing the existing complexity limits (may be null, in which case defaults are used)
*
* @return complexity limits with good faith bounds applied
*/
public static QueryComplexityLimits goodFaithLimits(@Nullable QueryComplexityLimits existing) {
if (existing == null) {
existing = QueryComplexityLimits.getDefaultLimits();
}
return !graphQlContext.getBoolean(GOOD_FAITH_INTROSPECTION_DISABLED, false);
int maxFields = Math.min(existing.getMaxFieldsCount(), GOOD_FAITH_MAX_FIELDS_COUNT);
int maxDepth = Math.min(existing.getMaxDepth(), GOOD_FAITH_MAX_DEPTH_COUNT);
return QueryComplexityLimits.newLimits()
.maxFieldsCount(maxFields)
.maxDepth(maxDepth)
.build();
}

public static class BadFaithIntrospectionError implements GraphQLError {
Expand Down Expand Up @@ -163,7 +163,7 @@ public ErrorClassification getErrorType() {
}

@Override
public List<SourceLocation> getLocations() {
public @Nullable List<SourceLocation> getLocations() {
return null;
}

Expand Down
5 changes: 0 additions & 5 deletions src/main/java/graphql/introspection/Introspection.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,21 +116,16 @@ public static boolean isEnabledJvmWide() {
public static Optional<ExecutionResult> isIntrospectionSensible(MergedSelectionSet mergedSelectionSet, ExecutionContext executionContext) {
GraphQLContext graphQLContext = executionContext.getGraphQLContext();

boolean isIntrospection = false;
for (String key : mergedSelectionSet.getKeys()) {
String fieldName = mergedSelectionSet.getSubField(key).getName();
if (fieldName.equals(SchemaMetaFieldDef.getName())
|| fieldName.equals(TypeMetaFieldDef.getName())) {
if (!isIntrospectionEnabled(graphQLContext)) {
return mkDisabledError(mergedSelectionSet.getSubField(key));
}
isIntrospection = true;
break;
}
}
if (isIntrospection) {
return GoodFaithIntrospection.checkIntrospection(executionContext);
}
return Optional.empty();
}

Expand Down
44 changes: 44 additions & 0 deletions src/main/java/graphql/validation/FragmentComplexityInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package graphql.validation;

import graphql.Internal;
import org.jspecify.annotations.NullMarked;

/**
* Holds pre-calculated complexity metrics for a fragment definition.
* This is used to efficiently track query complexity when fragments are spread
* at multiple locations in a query.
*/
@Internal
@NullMarked
class FragmentComplexityInfo {

private final int fieldCount;
private final int maxDepth;

FragmentComplexityInfo(int fieldCount, int maxDepth) {
this.fieldCount = fieldCount;
this.maxDepth = maxDepth;
}

/**
* @return the total number of fields in this fragment, including fields from nested fragments
*/
int getFieldCount() {
return fieldCount;
}

/**
* @return the maximum depth of fields within this fragment
*/
int getMaxDepth() {
return maxDepth;
}

@Override
public String toString() {
return "FragmentComplexityInfo{" +
"fieldCount=" + fieldCount +
", maxDepth=" + maxDepth +
'}';
}
}
Loading