Skip to content

bug: bound GraphQL query depth and complexity to prevent unauthenticated DoS - #1066

Open
devin-ai-integration[bot] wants to merge 1 commit into
masterfrom
devin/1788253799-graphql-query-limits
Open

devin-ai-integration[bot] wants to merge 1 commit into
masterfrom
devin/1788253799-graphql-query-limits

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 1, 2026

Copy link
Copy Markdown

Summary

/graphql is permitAll and the schema is cyclic (Article.comments -> CommentEdge.node: Comment -> Comment.article: Article!), while no graphql-java instrumentation was registered anywhere. A linear-sized anonymous query could therefore fan out exponentially — each nesting level of comments(first: 1000) triggers a paginated comment query plus one findById per comment in ArticleDatafetcher.getCommentArticle — exhausting the DB pool and heap.

New GraphQLQueryLimitConfig registers two Instrumentation beans, which DGS's autoconfiguration folds into its ChainedInstrumentation, so the limits apply to every operation regardless of resolver:

  • MaxQueryDepthInstrumentation(graphql.limit.max-query-depth, default 10) — a full comments -> edges -> node -> article cycle costs 4 levels, so recursion terminates after ~2 cycles.
  • MaxQueryComplexityInstrumentation(graphql.limit.max-query-complexity, default 50000, pageSizeAwareCalculator).

The custom calculator is the important part: graphql-java's default counts 1 per field and is blind to the first/last fan-out, so a first: 1000 connection would cost the same as first: 1. Instead each field multiplies its children by its requested page size:

complexity(field) = 1 + pageSize(field.arguments) * childComplexity   // pageSize = first ?? last ?? 1

So articles(first: 1000) { ... comments(first: 1000) { ... } } costs ~1000 * 1000 * fields and is rejected, while realistic paging (articles(first: 20) with comments(first: 20) ~= 5.6k) stays well under the cap. Both limits are overridable via application.properties.

Not changed (out of scope for this finding): /graphql stays anonymous — the RealWorld spec requires unauthenticated reads — and CursorPageParameter.MAX_LIMIT stays at 1000, since aggregate cost is now bounded by the complexity cap.

Tests: GraphQLQueryLimitConfigTest builds a minimal SDL reproducing the Article/Comment cycle and asserts the deep cyclic query and the wide 1000x1000 query are rejected, while a realistic 20x20 query passes both instrumentations.

Verification caveat: this environment cannot resolve Gradle plugins/dependencies (Maven Central and plugins.gradle.org are off the session network allowlist and the Gradle cache is empty), so compileJava/test/spotlessJavaCheck could not be run locally; the repo also has no CI workflow. Formatting was matched to google-java-format by hand.

Written by Devin

Devin-Org: engineering


Devin Review

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 3 potential issues.

Devin Review

Comment on lines +16 to +25
@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());

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.

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

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
return (FieldComplexityEnvironment environment, int childComplexity) ->
1 + pageSize(environment.getArguments()) * childComplexity;

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants