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
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def getDevelopmentVersion() {
gitRevParse.waitForProcessOutput(gitRevParseOutput, gitRevParseError)
def branchName = gitRevParseOutput.toString().trim().replaceAll('[/\\\\]', '-')

return makeDevelopmentVersion(["0.0.0", sanitizedBranchName, "SNAPSHOT"])
return makeDevelopmentVersion(["0.0.0", branchName, "SNAPSHOT"])
}

def reactiveStreamsVersion = '1.0.3'
Expand Down
20 changes: 18 additions & 2 deletions src/main/java/graphql/GraphQL.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@
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 org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.NullUnmarked;
Expand Down Expand Up @@ -567,7 +570,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 +609,15 @@ 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();
List<ValidationError> validationErrors = ParseAndValidate.validate(graphQLSchema, document, validationRulePredicate, locale);
QueryComplexityLimits limits = executionInput.getGraphQLContext().get(QueryComplexityLimits.KEY);

// Good Faith Introspection: disable the rule if good faith is off
if (!GoodFaithIntrospection.isEnabled(executionInput.getGraphQLContext())) {
Predicate<OperationValidationRule> existing = validationRulePredicate;
validationRulePredicate = rule -> rule != OperationValidationRule.GOOD_FAITH_INTROSPECTION && existing.test(rule);
}

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

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(GraphQLSchema graphQLSchema, Document parsedDocument, Predicate<OperationValidationRule> rulePredicate, 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
112 changes: 38 additions & 74 deletions src/main/java/graphql/introspection/GoodFaithIntrospection.java
Original file line number Diff line number Diff line change
@@ -1,43 +1,38 @@
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.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 +69,36 @@ 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());
}
}
}
return Optional.empty();
}

/**
* This makes an executable operation limited in size then which suits a good faith introspection query. This helps guard
* against malicious queries.
* Checks whether Good Faith Introspection is enabled for the given request context.
*
* @param executionContext the execution context
* @param graphQLContext the per-request context
*
* @return an executable operation
* @return true if good faith introspection checks should be applied
*/
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);

}

private static boolean isIntrospectionEnabled(GraphQLContext graphQlContext) {
public static boolean isEnabled(GraphQLContext graphQLContext) {
if (!isEnabledJvmWide()) {
return false;
}
return !graphQlContext.getBoolean(GOOD_FAITH_INTROSPECTION_DISABLED, false);
return !graphQLContext.getBoolean(GOOD_FAITH_INTROSPECTION_DISABLED, 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(QueryComplexityLimits existing) {
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 +127,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 +
'}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package graphql.validation;

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

/**
* Exception thrown when a good-faith introspection check fails during validation.
* This exception is NOT caught by the Validator — it propagates up to GraphQL.parseAndValidate()
* where it is converted to a {@link GoodFaithIntrospection.BadFaithIntrospectionError}.
*/
@Internal
@NullMarked
public class GoodFaithIntrospectionExceeded extends RuntimeException {

private final boolean tooBig;
private final String detail;

private GoodFaithIntrospectionExceeded(boolean tooBig, String detail) {
super(detail);
this.tooBig = tooBig;
this.detail = detail;
}

public static GoodFaithIntrospectionExceeded tooManyFields(String fieldCoordinate) {
return new GoodFaithIntrospectionExceeded(false, fieldCoordinate);
}

public static GoodFaithIntrospectionExceeded tooBigOperation(String message) {
return new GoodFaithIntrospectionExceeded(true, message);
}

public GoodFaithIntrospection.BadFaithIntrospectionError toBadFaithError() {
if (tooBig) {
return GoodFaithIntrospection.BadFaithIntrospectionError.tooBigOperation(detail);
}
return GoodFaithIntrospection.BadFaithIntrospectionError.tooManyFields(detail);
}

@Override
public synchronized Throwable fillInStackTrace() {
// No stack trace for performance - this is a control flow exception
return this;
}
}
3 changes: 3 additions & 0 deletions src/main/java/graphql/validation/OperationValidationRule.java
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,7 @@ public enum OperationValidationRule {

/** Defer directive must not be used in subscription operations. Requires operation context. */
DEFER_DIRECTIVE_ON_VALID_OPERATION,

/** Good faith introspection check. */
GOOD_FAITH_INTROSPECTION,
}
Loading