Sitelet https://github.com/fsprojects/FSharp.Data.GraphQL/pull/624
Skip to content

Fixed validation denial of service through fragment cycles and fragment bombs - #624

Open
xperiandri wants to merge 1 commit into
devfrom
validation-dos-fixes
Open

xperiandri wants to merge 1 commit into
devfrom
validation-dos-fixes

Conversation

@xperiandri

Copy link
Copy Markdown
Collaborator

Problem

Two documents could take any server down through validation alone:

  • query Q { hello } subscription S { ...F } fragment F on Query { ...F } crashed the process with a stack overflow in validateSubscriptionSingleRootField, even with Q selected and no subscription type in the schema.
  • A fragment bomb of 20 fragments (821 characters), each spreading the next one twice, took 31.5 seconds in CreateExecutionPlan, because the validation context inlines every fragment spread per path. Fragments spreading each other in a cycle grew factorially in the same way.

Changes

  • Size and depth check before validation. measureDocument counts, in linear time and iteratively, the selections that inlining fragment spreads produces over all operations and fragment definitions, and the nesting depth. The count of each fragment is computed once, as in Apollo Server's RecursiveSelectionsLimit. validateDocument rejects documents over DocumentLimitsDefaults.MaxRecursiveSelections (25 000) or MaxNestingDepth (128) before building anything else.
  • Error limit. Validation stops after DocumentLimitsDefaults.MaxValidationErrors (100) errors and adds "Too many validation errors, error limit reached. Validation aborted.", as graphql-js does.
  • Fragment cycles. The cycle rule uses Tarjan's algorithm (iterative, linear). Spreads of cyclic fragments are no longer inlined by the validation context or the @defer/@stream rules.
  • Subscription root field rule. Collects each fragment once per operation, so it terminates on cycles. It also counts the fields selected before a fragment spread, which it used to drop.
  • Unused variable rule. Searches each fragment once instead of once per path.
  • ValidationResult.collect. Accumulates errors in linear time instead of copying the accumulated list on every step.

The 25 000 default keeps validating and planning a document at the limit under a second. At 100 000 selections, planning alone takes about two seconds.

Behavior changes (in the release notes)

  • Documents over the limits are rejected.
  • Errors inside the spreads of cyclic fragments are no longer reported; the cycle error is.
  • The cycle rule reports each fragment of a cycle once, including fragments with an unknown type condition, and no longer reports fragments that only spread a cycle.

Tests

ValidationDoSTests.fs contains 24 xUnit tests. Stack-sensitive tests run on a 1 MiB thread with a timeout. Some documents are ported from Hot Chocolate's and Apollo Server's tests (MIT, attributed).

  • The original crash document, through Executor.CreateExecutionPlan.
  • Fragment bombs, an unused fragment bomb, a fragment of 1 000 fields shared by 200 operations, and fragments that all spread each other.
  • Hot Chocolate's recursive fragment, traversal bomb and expansion bomb (CVE-2025-32032) and deep fragment expansion documents.
  • Apollo's recursive selection threshold documents.
  • A 10 000-fragment chain, the nesting limit and the error limit.
  • 50 000 errors in linear time and a 200 000-field document.

The full FAKE pipeline passes locally: 788 unit tests (5 skipped, as before) and 108 integration tests.

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 3, 2026 00:20
…nt bombs

- Validation measures, before building its context, how many selections inlining the fragment spreads produces and how deep it nests, and rejects documents over `DocumentLimitsDefaults.MaxRecursiveSelections` (25 000) or `MaxNestingDepth` (128)
- Validation stops after `DocumentLimitsDefaults.MaxValidationErrors` (100) errors, as graphql-js does
- The subscription single root field rule collects each fragment once per operation, so a fragment spreading itself no longer overflows the stack, and counts the fields selected before a fragment spread
- The fragment cycle rule finds cycles with Tarjan's algorithm in linear time, and spreads of cyclic fragments are no longer inlined by the other rules
- The unused variable rule searches each fragment once
- `ValidationResult.collect` accumulates errors in linear time

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Subscription validation still counts duplicate selections with the same response key as separate root fields.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Adds validation safeguards against fragment-cycle stack overflows and fragment-bomb denial-of-service attacks.

Changes:

  • Adds document size, depth, and validation-error limits.
  • Introduces cycle-safe fragment traversal and linear error collection.
  • Adds regression tests and release notes.
File Description
RELEASE_NOTES.md Documents validation behavior changes.
DocumentLimits.fs Defines default validation limits.
FSharp.Data.GraphQL.Shared.fsproj Includes the limits module.
Validation.fs Implements limits and cycle-safe validation.
ValidationTypes.fs Optimizes error accumulation.
FSharp.Data.GraphQL.Tests.fsproj Includes the new tests.
ValidationDoSTests.fs Covers denial-of-service regressions.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +723 to +735
let visitedFragments = HashSet<string> (StringComparer.Ordinal)
let rec getFieldNames (names : string list) (selectionSet : Selection list) =
(names, selectionSet)
||> List.fold (fun acc ->
function
| Field field -> field.AliasOrName :: acc
| InlineFragment frag -> getFieldNames acc frag.SelectionSet
| FragmentSpread spread when visitedFragments.Add spread.Name ->
match fragments.TryGetValue spread.Name with
| true, frag -> getFieldNames acc frag.SelectionSet
| false, _ -> acc
| FragmentSpread _ -> acc)
let fieldNames = getFieldNames [] def.SelectionSet
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown

Test Results

    9 files      9 suites   14m 18s ⏱️
  901 tests   896 ✅  5 💤 0 ❌
2 703 runs  2 688 ✅ 15 💤 0 ❌

Results for commit df09a89.

@xperiandri
xperiandri added this pull request to stack #627 October 3, 2026 01:04

This branch has not been deployed

No deployments
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.

2 participants