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
42 changes: 42 additions & 0 deletions src/main/java/io/spring/graphql/GraphQLQueryLimitConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.spring.graphql;

import graphql.analysis.FieldComplexityCalculator;
import graphql.analysis.FieldComplexityEnvironment;
import graphql.analysis.MaxQueryComplexityInstrumentation;
import graphql.analysis.MaxQueryDepthInstrumentation;
import graphql.execution.instrumentation.Instrumentation;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class GraphQLQueryLimitConfig {

@Bean
public Instrumentation maxQueryDepthInstrumentation(
@Value("${graphql.limit.max-query-depth:10}") int maxQueryDepth) {
return new MaxQueryDepthInstrumentation(maxQueryDepth);
}

@Bean
public Instrumentation maxQueryComplexityInstrumentation(
@Value("${graphql.limit.max-query-complexity:50000}") int maxQueryComplexity) {
return new MaxQueryComplexityInstrumentation(maxQueryComplexity, pageSizeAwareCalculator());
Comment on lines +16 to +25

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Both limits reach production execution

DGS 4.9.21 collects every Instrumentation bean into one chain. Both independently tested limits therefore run on production requests.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

static FieldComplexityCalculator pageSizeAwareCalculator() {
return (FieldComplexityEnvironment environment, int childComplexity) ->
1 + pageSize(environment.getArguments()) * childComplexity;
Comment on lines +28 to +30

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Variable page sizes remain accounted

graphql-java supplies coerced variable values through FieldComplexityEnvironment. $first and $last therefore receive the same multiplier as inline values.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +29 to +30

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Complexity overflow bypasses query limits

Large first or last values overflow pageSizeAwareCalculator below the limit. Anonymous requests can execute the expensive queries this protection targets.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

private static int pageSize(Map<String, Object> arguments) {
Object requested =
arguments.get("first") != null ? arguments.get("first") : arguments.get("last");
if (requested instanceof Number) {
int size = ((Number) requested).intValue();
return size > 0 ? size : 1;
}
return 1;
}
}
3 changes: 3 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ spring.datasource.username=
spring.datasource.password=
spring.jackson.deserialization.UNWRAP_ROOT_VALUE=true

graphql.limit.max-query-depth=10
graphql.limit.max-query-complexity=50000

image.default=https://static.productionready.io/images/smiley-cyrus.jpg

jwt.secret=nRvyYC4soFxBdZ-F-5Nnzz5USXstR1YylsTd-mA0aKtI9HUlriGrtkf-TiuDapkLiUCogO3JOK7kwZisrHp6wA
Expand Down
92 changes: 92 additions & 0 deletions src/test/java/io/spring/graphql/GraphQLQueryLimitConfigTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package io.spring.graphql;

import static org.assertj.core.api.Assertions.assertThat;

import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.execution.instrumentation.Instrumentation;
import graphql.schema.GraphQLSchema;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;

public class GraphQLQueryLimitConfigTest {

private static final String SDL =
String.join(
"\n",
"type Query { articles(first: Int, after: String, last: Int, before: String): "
+ "ArticlesConnection }",
"type ArticlesConnection { edges: [ArticleEdge] }",
"type ArticleEdge { cursor: String!, node: Article }",
"type Article { slug: String!, title: String!, comments(first: Int, after: String, "
+ "last: Int, before: String): CommentsConnection }",
"type CommentsConnection { edges: [CommentEdge] }",
"type CommentEdge { cursor: String!, node: Comment }",
"type Comment { id: ID!, body: String!, article: Article! }");

@Test
public void should_reject_queries_exceeding_maximum_depth() {
GraphQLQueryLimitConfig config = new GraphQLQueryLimitConfig();
ExecutionResult executionResult =
graphWith(config.maxQueryDepthInstrumentation(10)).execute(depthQuery());

assertThat(executionResult.getErrors()).isNotEmpty();
assertThat(errorMessages(executionResult)).contains("maximum query depth exceeded");
}

@Test
public void should_reject_queries_exceeding_maximum_complexity() {
GraphQLQueryLimitConfig config = new GraphQLQueryLimitConfig();
ExecutionResult executionResult =
graphWith(config.maxQueryComplexityInstrumentation(50000))
.execute(
"{ articles(first: 1000) { edges { node { comments(first: 1000) { "
+ "edges { node { body } } } } } } }");

assertThat(executionResult.getErrors()).isNotEmpty();
assertThat(errorMessages(executionResult)).contains("maximum query complexity exceeded");
}

@Test
public void should_allow_realistic_queries_within_both_limits() {
String query =
"{ articles(first: 20) { edges { node { title comments(first: 20) { "
+ "edges { node { body } } } } } } }";
GraphQLQueryLimitConfig config = new GraphQLQueryLimitConfig();

assertThat(graphWith(config.maxQueryDepthInstrumentation(10)).execute(query).getErrors())
.isEmpty();
ExecutionResult executionResult =
graphWith(config.maxQueryComplexityInstrumentation(50000)).execute(query);
assertThat(executionResult.getErrors()).isEmpty();
}

private static GraphQL graphWith(Instrumentation instrumentation) {
TypeDefinitionRegistry registry = new SchemaParser().parse(SDL);
GraphQLSchema schema =
new SchemaGenerator()
.makeExecutableSchema(registry, RuntimeWiring.newRuntimeWiring().build());
return GraphQL.newGraphQL(schema).instrumentation(instrumentation).build();
}

private static String depthQuery() {
return "{ articles { edges { node { " + cycle(5) + " } } } }";
}

private static String cycle(int remaining) {
if (remaining == 0) {
return "slug";
}
return "comments { edges { node { article { " + cycle(remaining - 1) + " } } } }";
}

private static String errorMessages(ExecutionResult executionResult) {
return executionResult.getErrors().stream()
.map(error -> error.getMessage())
.collect(Collectors.joining("\n"));
}
}