CVE-2023-40180: Uncontrolled Resource Consumption (CWE-400)
value:  silverstripe-graphql is a package which serves Silverstripe data in GraphQL representations. An attacker could use a recursive graphql query to execute a Distributed Denial of Service attack (DDOS attack) against a website. This mostly affects websites with publicly exposed graphql schemas. If your Silverstripe CMS project does not expose a public facing graphql schema, a user account is required to trigger the DDOS attack. If your site is hosted behind a content delivery network (CDN), such as Imperva or CloudFlare, this may further mitigate the risk. This issue has been addressed in versions 3.8.2, 4.1.3, 4.2.5, 4.3.4, and 5.0.3. Users are advised to upgrade. There are no known workarounds for this vulnerability.
CWE description: The product does not properly control the allocation and maintenance of a limited resource, thereby enabling an actor to influence the amount of resources consumed, eventually leading to the exhaustion of available resources.
CVSS v3 base score: 7.5
Published: 2023-10-16T19:15Z
Repository: https://github.com/silverstripe/silverstripe-graphql
Language: YAML
Files changed: _config/schema-global.yml, src/Controller.php, src/QueryHandler/QueryHandler.php, tests/Fake/FakeSiteTree.php, tests/Schema/IntegrationTest.php, tests/Schema/_testCustomComplexityLimit/config.yml, tests/Schema/_testCustomComplexityLimit/models.yml, tests/Schema/_testCustomDepthLimit/config.yml, tests/Schema/_testCustomDepthLimit/models.yml, tests/Schema/_testCustomNodeLimit/config.yml, tests/Schema/_testCustomNodeLimit/models.yml, tests/Schema/_testDefaultDepthLimit/config.yml, tests/Schema/_testDefaultDepthLimit/models.yml, tests/Schema/_testDefaultNodeLimit/models.yml, tests/Schema/_testGlobalRuleNotRemoved/config.yml, tests/Schema/_testGlobalRuleNotRemoved/models.yml, tests/Schema/fixtures.yml
Commit date: 2023-10-16 13:45:17 +1300
Commit message: [CVE-2023-40180] Add protection against recursive queries (#558)


Diff: diff --git a/_config/schema-global.yml b/_config/schema-global.yml
index 2869aba..8c73913 100644
--- a/_config/schema-global.yml
+++ b/_config/schema-global.yml
@@ -8,10 +8,12 @@ SilverStripe\GraphQL\Schema\Schema:
         JSONBlob:
           serialiser: 'SilverStripe\GraphQL\Schema\Resolver\JSONResolver::serialise'
           valueParser: 'SilverStripe\GraphQL\Schema\Resolver\JSONResolver::parseValue'
           literalParser: 'SilverStripe\GraphQL\Schema\Resolver\JSONResolver::parseLiteral'
       config:
+        max_query_depth: 15
+        max_query_nodes: 500
         resolverStrategy: 'SilverStripe\GraphQL\Schema\Resolver\DefaultResolverStrategy::getResolverMethod'
         defaultResolver: 'SilverStripe\GraphQL\Schema\Resolver\DefaultResolver::defaultFieldResolver'
         modelCreators:
           - 'SilverStripe\GraphQL\Schema\DataObject\ModelCreator'
         defaultBulkLoad:
diff --git a/src/Controller.php b/src/Controller.php
index 571c9e5..084c163 100644
--- a/src/Controller.php
+++ b/src/Controller.php
@@ -28,10 +28,11 @@ use SilverStripe\GraphQL\QueryHandler\UserContextProvider;
 use SilverStripe\GraphQL\Schema\SchemaBuilder;
 use SilverStripe\Security\Member;
 use SilverStripe\Security\Permission;
 use SilverStripe\Versioned\Versioned;
 use BadMethodCallException;
+use SilverStripe\Core\ClassInfo;
 
 /**
  * Top level controller for handling graphql requests.
  * @skipUpgrade
  */
@@ -111,12 +112,15 @@ class Controller extends BaseController
                     $this->getSchemaKey()
                 ));
             }
             $handler = $this->getQueryHandler();
             $this->applyContext($handler);
-            $queryDocument = Parser::parse(new Source($query));
             $ctx = $handler->getContext();
+            if (ClassInfo::hasMethod($handler, 'validateQueryBeforeParsing')) {
+                $handler->validateQueryBeforeParsing($query, $ctx);
+            }
+            $queryDocument = Parser::parse(new Source($query));
             $result = $handler->query($graphqlSchema, $query, $variables);
 
             // Fire an eventYou
             $eventContext = [
                 'schema' => $graphqlSchema,
diff --git a/src/QueryHandler/QueryHandler.php b/src/QueryHandler/QueryHandler.php
index 989b072..046205f 100644
--- a/src/QueryHandler/QueryHandler.php
+++ b/src/QueryHandler/QueryHandler.php
@@ -3,18 +3,24 @@
 
 namespace SilverStripe\GraphQL\QueryHandler;
 
 use GraphQL\Error\Error;
 use GraphQL\Error\SyntaxError;
+use GraphQL\Error\UserError;
 use GraphQL\Executor\ExecutionResult;
 use GraphQL\GraphQL;
 use GraphQL\Language\AST\DocumentNode;
 use GraphQL\Language\AST\NodeKind;
+use GraphQL\Language\Lexer;
 use GraphQL\Language\Parser;
 use GraphQL\Language\Source;
 use GraphQL\Language\SourceLocation;
+use GraphQL\Language\Token;
 use GraphQL\Type\Schema as GraphQLSchema;
+use GraphQL\Validator\DocumentValidator;
+use GraphQL\Validator\Rules\QueryComplexity;
+use GraphQL\Validator\Rules\QueryDepth;
 use SilverStripe\Control\Director;
 use SilverStripe\Core\Config\Configurable;
 use SilverStripe\Core\Extensible;
 use SilverStripe\Core\Injector\Injectable;
 use SilverStripe\Core\Injector\Injector;
@@ -22,10 +28,11 @@ use SilverStripe\GraphQL\Middleware\QueryMiddleware;
 use SilverStripe\GraphQL\Permission\MemberAware;
 use SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider;
 use SilverStripe\GraphQL\PersistedQuery\PersistedQueryProvider;
 use SilverStripe\GraphQL\Schema\Interfaces\ContextProvider;
 use SilverStripe\GraphQL\Schema\Schema;
+use SilverStripe\GraphQL\Schema\SchemaConfig;
 use SilverStripe\ORM\ValidationException;
 
 /**
  * This class is responsible for taking query information from a controller,
  * processing it through middlewares, extracting the results from the GraphQL schema,
@@ -97,16 +104,65 @@ class QueryHandler implements
         if ($this->errorHandler) {
             set_error_handler($this->errorHandler);
         }
         $context = $this->getContext();
         $last = function ($schema, $query, $context, $vars) {
-            return GraphQL::executeQuery($schema, $query, null, $context, $vars);
+            if (is_string($query)) {
+                $this->validateQueryBeforeParsing($query, $context);
+            }
+
+            $validationRules = DocumentValidator::allRules();
+            if (isset($context[SchemaConfigProvider::KEY])) {
+                /** @var SchemaConfig $config */
+                $config = $context[SchemaConfigProvider::KEY];
+                $maxDepth = $config->get('max_query_depth');
+                $maxComplexity = $config->get('max_query_complexity');
+                if ($maxDepth) {
+                    $validationRules[QueryDepth::class] = new QueryDepth($maxDepth);
+                }
+                if ($maxComplexity) {
+                    $validationRules[QueryComplexity::class] = new QueryComplexity($maxComplexity);
+                }
+            }
+            return GraphQL::executeQuery($schema, $query, null, $context, $vars, null, null, $validationRules);
         };
 
         return $this->callMiddleware($schema, $query, $context, $vars ?? [], $last);
     }
 
+    /**
+     * Validate a query before parsing it in case there are issues we can catch early.
+     */
+    public function validateQueryBeforeParsing(string $query, array $context): void
+    {
+        if (!isset($context[SchemaConfigProvider::KEY])) {
+            return;
+        }
+
+        /** @var SchemaConfig $config */
+        $config = $context[SchemaConfigProvider::KEY];
+        $maxNodes = $config->get('max_query_nodes');
+        if (!$maxNodes) {
+            return;
+        }
+
+        $lexer = new Lexer(new Source($query));
+        $numNodes = 0;
+
+        // Check how many nodes there are in this query
+        do {
+            $next = $lexer->advance();
+            if ($next->kind === Token::NAME) {
+                $numNodes++;
+            }
+        } while ($next->kind !== Token::EOF && $numNodes <= $maxNodes);
+
+        // Throw a UserError if there are too many nodes
+        if ($numNodes > $maxNodes) {
+            throw new UserError("GraphQL query body must not be longer than $maxNodes nodes.");
+        }
+    }
 
     /**
      * get query from persisted id, return null if not found
      *
      * @param string $id
diff --git a/tests/Fake/FakeSiteTree.php b/tests/Fake/FakeSiteTree.php
index af4ddbd..087fff0 100644
--- a/tests/Fake/FakeSiteTree.php
+++ b/tests/Fake/FakeSiteTree.php
@@ -13,10 +13,18 @@ class FakeSiteTree extends DataObject implements TestOnly
     private static $db = [
         'Title' => 'Varchar',
         'Content' => 'HTMLText'
     ];
 
+    private static $has_one = [
+        'Parent' => self::class,
+    ];
+
+    private static $has_many = [
+        'Children' => self::class,
+    ];
+
     private static $extensions = [
         Versioned::class,
     ];
 
     public function canView($member = null)
diff --git a/tests/Schema/IntegrationTest.php b/tests/Schema/IntegrationTest.php
index c8dc249..fede9d3 100644
--- a/tests/Schema/IntegrationTest.php
+++ b/tests/Schema/IntegrationTest.php
@@ -34,14 +34,20 @@ use SilverStripe\GraphQL\Tests\Fake\Inheritance\A;
 use SilverStripe\GraphQL\Tests\Fake\IntegrationTestResolver;
 use SilverStripe\Security\Member;
 use Symfony\Component\Filesystem\Filesystem;
 use GraphQL\Type\Schema as GraphQLSchema;
 use Exception;
+use GraphQL\Error\Error as GraphQLError;
+use GraphQL\Validator\DocumentValidator;
+use GraphQL\Validator\Rules\CustomValidationRule;
+use GraphQL\Validator\Rules\QueryComplexity;
+use GraphQL\Validator\Rules\QueryDepth;
+use GraphQL\Validator\ValidationContext;
+use ReflectionProperty;
 
 class IntegrationTest extends SapphireTest
 {
-
     protected static $extra_dataobjects = [
         FakePage::class,
         DataObjectFake::class,
         FakeSiteTree::class,
         FakeRedirectorPage::class,
@@ -1195,10 +1201,224 @@ GRAPHQL;
             HTACCESS,
             file_get_contents($file)
         );
     }
 
+    /**
+     * @dataProvider provideDefaultDepthLimit
+     */
+    public function testDefaultDepthLimit(int $queryDepth, int $limit)
+    {
+        // This global rule should be ignored.
+        DocumentValidator::addRule(new QueryDepth(1));
+
+        try {
+            $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
+            $this->runDepthLimitTest($queryDepth, $limit, $schema);
+        } finally {
+            $this->removeDocumentValidatorRule(QueryDepth::class);
+        }
+    }
+
+    public function provideDefaultDepthLimit()
+    {
+        return $this->createProviderForComplexityOrDepth(15);
+    }
+
+    /**
+     * @dataProvider provideCustomDepthLimit
+     */
+    public function testCustomDepthLimit(int $queryDepth, int $limit)
+    {
+        // This global rule should be ignored.
+        DocumentValidator::addRule(new QueryDepth(1));
+
+        try {
+            $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
+            $this->runDepthLimitTest($queryDepth, $limit, $schema);
+        } finally {
+            $this->removeDocumentValidatorRule(QueryDepth::class);
+        }
+    }
+
+    public function provideCustomDepthLimit()
+    {
+        return $this->createProviderForComplexityOrDepth(25);
+    }
+
+    /**
+     * @dataProvider provideCustomComplexityLimit
+     */
+    public function testCustomComplexityLimit(int $queryComplexity, int $limit)
+    {
+        // This global rule should be ignored.
+        DocumentValidator::addRule(new QueryComplexity(1));
+
+        try {
+            $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
+            $this->runComplexityLimitTest($queryComplexity, $limit, $schema);
+        } finally {
+            $this->removeDocumentValidatorRule(QueryComplexity::class);
+        }
+    }
+
+    public function provideCustomComplexityLimit()
+    {
+        return $this->createProviderForComplexityOrDepth(10);
+    }
+
+    /**
+     * @dataProvider provideDefaultNodeLimit
+     */
+    public function testDefaultNodeLimit(int $numNodes, int $limit)
+    {
+        $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
+        $this->runNodeLimitTest($numNodes, $limit, $schema);
+    }
+
+    public function provideDefaultNodeLimit()
+    {
+        return $this->createProviderForComplexityOrDepth(500);
+    }
+
+    /**
+     * @dataProvider provideCustomNodeLimit
+     */
+    public function testCustomNodeLimit(int $numNodes, int $limit)
+    {
+        $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
+        $this->runNodeLimitTest($numNodes, $limit, $schema);
+    }
+
+    public function provideCustomNodeLimit()
+    {
+        return $this->createProviderForComplexityOrDepth(200);
+    }
+
+    public function testGlobalRuleNotRemoved()
+    {
+        // This global rule should NOT be ignored.
+        DocumentValidator::addRule(new CustomValidationRule('never-passes', function (ValidationContext $context) {
+            $context->reportError(new GraphQLError('This is the custom rule'));
+            return [];
+        }));
+
+        try {
+            $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
+            $result = $this->querySchema($schema, $this->craftRecursiveQuery(15));
+            $this->assertFailure($result);
+            $this->assertErrorMatchingRegex($result, '/^This is the custom rule$/');
+        } finally {
+            $this->removeDocumentValidatorRule('never-passes');
+        }
+    }
+
+    private function removeDocumentValidatorRule(string $ruleName): void
+    {
+        $reflectionRules = new ReflectionProperty(DocumentValidator::class, 'rules');
+        $reflectionRules->setAccessible(true);
+        $rules = $reflectionRules->getValue();
+        unset($rules[$ruleName]);
+        $reflectionRules->setValue($rules);
+    }
+
+    private function createProviderForComplexityOrDepth(int $limit): array
+    {
+        return [
+            'far less than limit' => [1, $limit],
+            'one less than limit' => [$limit - 1, $limit],
+            'exactly at the limit' => [$limit, $limit],
+            'one more than limit' => [$limit + 1, $limit],
+            'far more than limit' => [$limit + 25, $limit],
+        ];
+    }
+
+    private function runDepthLimitTest(int $queryDepth, int $maxDepth, Schema $schema): void
+    {
+        $result = $this->querySchema($schema, $this->craftRecursiveQuery($queryDepth));
+        if ($queryDepth > $maxDepth) {
+            $this->assertFailure($result);
+            $this->assertErrorMatchingRegex($result, '/^Max query depth should be ' . $maxDepth . ' but got ' . $queryDepth . '\.$/');
+        } else {
+            // Note that the depth limit is based on the depth of the QUERY, not of the RESULTS, so all we really care about
+            // is that the query was successful, not what the results were.
+            $this->assertSuccess($result);
+        }
+    }
+
+    private function runComplexityLimitTest(int $queryComplexity, int $maxComplexity, Schema $schema): void
+    {
+        $result = $this->querySchema($schema, $this->craftComplexQuery($queryComplexity));
+        if ($queryComplexity > $maxComplexity) {
+            $this->assertFailure($result);
+            $this->assertErrorMatchingRegex($result, '/^Max query complexity should be ' . $maxComplexity . ' but got ' . $queryComplexity . '\.$/');
+        } else {
+            // Note that the complexity limit is based on the complexity of the QUERY, not of the RESULTS, so all we really care about
+            // is that the query was successful, not what the results were.
+            $this->assertSuccess($result);
+        }
+    }
+
+    private function runNodeLimitTest(int $queryNodeCount, int $maxNodes, Schema $schema): void
+    {
+        $result = $this->querySchema($schema, $this->craftComplexQuery($queryNodeCount - 1));
+        if ($queryNodeCount > $maxNodes) {
+            $this->assertFailure($result);
+            $this->assertErrorMatchingRegex($result, '/^GraphQL query body must not be longer than ' . $maxNodes . ' nodes\.$/');
+        } else {
+            // Note that the complexity limit is based on the complexity of the QUERY, not of the RESULTS, so all we really care about
+            // is that the query was successful, not what the results were.
+            $this->assertSuccess($result);
+        }
+    }
+
+    private function craftRecursiveQuery(int $queryDepth): string
+    {
+        $query = 'query{ readFakeSiteTrees { nodes {';
+
+        for ($i = 0; $i < $queryDepth; $i++) {
+            if ($i % 3 === 0) {
+                $query .= 'id title';
+            } elseif ($i % 3 === 1) {
+                $query .= ' parent {';
+            } elseif ($i % 3 === 2) {
+                if ($i === $queryDepth - 1) {
+                    $query .= 'id title';
+                } else {
+                    $query .= 'id title children { nodes {';
+                }
+            }
+        }
+
+        $endsWith = strrpos($query, 'id title') === strlen($query) - strlen('id title');
+        $query .= $endsWith ? '' : 'id title';
+        // Add all of the closing brackets
+        $numChars = array_count_values(str_split($query));
+        for ($i = 0; $i < $numChars['{']; $i++) {
+            $query .= '}';
+        }
+
+        return $query;
+    }
+
+    private function craftComplexQuery(int $queryComplexity): string
+    {
+        $query = 'query{ readOneFakeSiteTree { id';
+
+        // skip the first two complexity, because those are taken up by "readOneFakeSiteTree { id" above
+        for ($i = 0; $i < $queryComplexity - 2; $i++) {
+            $query .= ' id';
+        }
+        // Add all of the closing brackets
+        $numChars = array_count_values(str_split($query));
+        for ($i = 0; $i < $numChars['{']; $i++) {
+            $query .= '}';
+        }
+
+        return $query;
+    }
+
     /**
      * @param TestSchemaBuilder $factory
      * @return Schema
      * @throws SchemaBuilderException
      * @throws SchemaNotFoundException
@@ -1252,37 +1472,57 @@ GRAPHQL;
     }
 
     private function assertSuccess(array $result)
     {
         $errors = $result['errors'] ?? [];
-        if (!empty($errors)) {
-            $this->fail('Failed to assert successful query. Got errors: ' . json_encode($errors, JSON_PRETTY_PRINT));
-        }
+        $this->assertEmpty($errors, 'Failed to assert successful query. Got errors: ' . json_encode($errors, JSON_PRETTY_PRINT));
         $error = $result['error'] ?? null;
-        if ($error) {
-            $this->fail('Failed to assert successful query. Got error: ' . $error);
-        }
+        $this->assertFalse((bool) $error, 'Failed to assert successful query. Got error: ' . $error);
     }
 
     private function assertFailure(array $result)
     {
-        $errors = $result['errors'] ?? [];
+        $errors = $result['errors'] ?? $result['error'] ?? [];
         if (empty($errors)) {
             $this->fail('Failed to assert that query was not successful');
         }
     }
 
     private function assertMissingField(array $result, string $fieldName)
     {
+        $this->assertErrorMatchingRegex(
+            $result,
+            '/^Cannot query field "' . $fieldName . '"/',
+            'Failed to assert that result was missing field "' . $fieldName . '"'
+        );
+    }
+
+    private function assertErrorMatchingRegex(
+        array $result,
+        string $errorRegex,
+        string $message = 'Failed to assert that expected error was present.'
+    ) {
         $errors = $result['errors'] ?? [];
+        if (isset($result['error'])) {
+            $errors[] = ['message' => $result['error']];
+        }
+        $errorMessages = [];
+        $foundError = false;
         foreach ($errors as $error) {
-            if (preg_match('/^Cannot query field "' . $fieldName . '"/', $error['message'] ?? '')) {
-                return;
+            if (!isset($error['message'])) {
+                continue;
             }
+            if (preg_match($errorRegex, $error['message'])) {
+                $foundError = true;
+                break;
+            }
+            $errorMessages[] = '"' . $error['message'] . '"';
         }
-
-        $this->fail('Failed to assert that result was missing field "' . $fieldName . '"');
+        $this->assertTrue(
+            $foundError,
+            $message . ' Regex was: ' . $errorRegex . ', Errors were: ' . implode(', ', $errorMessages)
+        );
     }
 
     private function assertResults(array $expected, array $actual)
     {
         $this->assertEquals(json_encode($expected), json_encode($actual));
diff --git a/tests/Schema/_testCustomComplexityLimit/config.yml b/tests/Schema/_testCustomComplexityLimit/config.yml
new file mode 100644
index 0000000..953230f
--- /dev/null
+++ b/tests/Schema/_testCustomComplexityLimit/config.yml
@@ -0,0 +1 @@
+max_query_complexity: 10
diff --git a/tests/Schema/_testCustomComplexityLimit/models.yml b/tests/Schema/_testCustomComplexityLimit/models.yml
new file mode 100644
index 0000000..18ff2d2
--- /dev/null
+++ b/tests/Schema/_testCustomComplexityLimit/models.yml
@@ -0,0 +1,3 @@
+SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
+  fields: '*'
+  operations: '*'
diff --git a/tests/Schema/_testCustomDepthLimit/config.yml b/tests/Schema/_testCustomDepthLimit/config.yml
new file mode 100644
index 0000000..abdba25
--- /dev/null
+++ b/tests/Schema/_testCustomDepthLimit/config.yml
@@ -0,0 +1,7 @@
+# this just makes it easier to craft the recursive query programatically
+modelConfig:
+  DataObject:
+    nested_query_plugins:
+      paginateList: true
+
+max_query_depth: 25
diff --git a/tests/Schema/_testCustomDepthLimit/models.yml b/tests/Schema/_testCustomDepthLimit/models.yml
new file mode 100644
index 0000000..18ff2d2
--- /dev/null
+++ b/tests/Schema/_testCustomDepthLimit/models.yml
@@ -0,0 +1,3 @@
+SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
+  fields: '*'
+  operations: '*'
diff --git a/tests/Schema/_testCustomNodeLimit/config.yml b/tests/Schema/_testCustomNodeLimit/config.yml
new file mode 100644
index 0000000..ea1184a
--- /dev/null
+++ b/tests/Schema/_testCustomNodeLimit/config.yml
@@ -0,0 +1 @@
+max_query_nodes: 200
diff --git a/tests/Schema/_testCustomNodeLimit/models.yml b/tests/Schema/_testCustomNodeLimit/models.yml
new file mode 100644
index 0000000..18ff2d2
--- /dev/null
+++ b/tests/Schema/_testCustomNodeLimit/models.yml
@@ -0,0 +1,3 @@
+SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
+  fields: '*'
+  operations: '*'
diff --git a/tests/Schema/_testDefaultDepthLimit/config.yml b/tests/Schema/_testDefaultDepthLimit/config.yml
new file mode 100644
index 0000000..98f4298
--- /dev/null
+++ b/tests/Schema/_testDefaultDepthLimit/config.yml
@@ -0,0 +1,5 @@
+# this just makes it easier to craft the recursive query programatically
+modelConfig:
+  DataObject:
+    nested_query_plugins:
+      paginateList: true
diff --git a/tests/Schema/_testDefaultDepthLimit/models.yml b/tests/Schema/_testDefaultDepthLimit/models.yml
new file mode 100644
index 0000000..18ff2d2
--- /dev/null
+++ b/tests/Schema/_testDefaultDepthLimit/models.yml
@@ -0,0 +1,3 @@
+SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
+  fields: '*'
+  operations: '*'
diff --git a/tests/Schema/_testDefaultNodeLimit/models.yml b/tests/Schema/_testDefaultNodeLimit/models.yml
new file mode 100644
index 0000000..18ff2d2
--- /dev/null
+++ b/tests/Schema/_testDefaultNodeLimit/models.yml
@@ -0,0 +1,3 @@
+SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
+  fields: '*'
+  operations: '*'
diff --git a/tests/Schema/_testGlobalRuleNotRemoved/config.yml b/tests/Schema/_testGlobalRuleNotRemoved/config.yml
new file mode 100644
index 0000000..98f4298
--- /dev/null
+++ b/tests/Schema/_testGlobalRuleNotRemoved/config.yml
@@ -0,0 +1,5 @@
+# this just makes it easier to craft the recursive query programatically
+modelConfig:
+  DataObject:
+    nested_query_plugins:
+      paginateList: true
diff --git a/tests/Schema/_testGlobalRuleNotRemoved/models.yml b/tests/Schema/_testGlobalRuleNotRemoved/models.yml
new file mode 100644
index 0000000..18ff2d2
--- /dev/null
+++ b/tests/Schema/_testGlobalRuleNotRemoved/models.yml
@@ -0,0 +1,3 @@
+SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
+  fields: '*'
+  operations: '*'
diff --git a/tests/Schema/fixtures.yml b/tests/Schema/fixtures.yml
index c44b5e9..a0c6276 100644
--- a/tests/Schema/fixtures.yml
+++ b/tests/Schema/fixtures.yml
@@ -1,10 +1,11 @@
 SilverStripe\Security\Member:
   author1:
     FirstName: Author1
   author2:
     FirstName: Author2
+
 SilverStripe\GraphQL\Tests\Fake\FakeProductPage:
   productPage1:
     Title: This is product page one
     BannerContent: This is product one banner
   productPage2:

Vulnerable code:             $queryDocument = Parser::parse(new Source($query));
            return GraphQL::executeQuery($schema, $query, null, $context, $vars);

        if (!empty($errors)) {
            $this->fail('Failed to assert successful query. Got errors: ' . json_encode($errors, JSON_PRETTY_PRINT));
        }
        if ($error) {
            $this->fail('Failed to assert successful query. Got error: ' . $error);
        }
        $errors = $result['errors'] ?? [];
            if (preg_match('/^Cannot query field "' . $fieldName . '"/', $error['message'] ?? '')) {
                return;

        $this->fail('Failed to assert that result was missing field "' . $fieldName . '"');
Fixed code:         max_query_depth: 15
        max_query_nodes: 500
use SilverStripe\Core\ClassInfo;
            if (ClassInfo::hasMethod($handler, 'validateQueryBeforeParsing')) {
                $handler->validateQueryBeforeParsing($query, $ctx);
            }
            $queryDocument = Parser::parse(new Source($query));
use GraphQL\Error\UserError;
use GraphQL\Language\Lexer;
use GraphQL\Language\Token;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\Rules\QueryDepth;
use SilverStripe\GraphQL\Schema\SchemaConfig;
            if (is_string($query)) {
                $this->validateQueryBeforeParsing($query, $context);
            }

            $validationRules = DocumentValidator::allRules();
            if (isset($context[SchemaConfigProvider::KEY])) {
                /** @var SchemaConfig $config */
                $config = $context[SchemaConfigProvider::KEY];
                $maxDepth = $config->get('max_query_depth');
                $maxComplexity = $config->get('max_query_complexity');
                if ($maxDepth) {
                    $validationRules[QueryDepth::class] = new QueryDepth($maxDepth);
                }
                if ($maxComplexity) {
                    $validationRules[QueryComplexity::class] = new QueryComplexity($maxComplexity);
                }
            }
            return GraphQL::executeQuery($schema, $query, null, $context, $vars, null, null, $validationRules);
    /**
     * Validate a query before parsing it in case there are issues we can catch early.
     */
    public function validateQueryBeforeParsing(string $query, array $context): void
    {
        if (!isset($context[SchemaConfigProvider::KEY])) {
            return;
        }

        /** @var SchemaConfig $config */
        $config = $context[SchemaConfigProvider::KEY];
        $maxNodes = $config->get('max_query_nodes');
        if (!$maxNodes) {
            return;
        }

        $lexer = new Lexer(new Source($query));
        $numNodes = 0;

        // Check how many nodes there are in this query
        do {
            $next = $lexer->advance();
            if ($next->kind === Token::NAME) {
                $numNodes++;
            }
        } while ($next->kind !== Token::EOF && $numNodes <= $maxNodes);

        // Throw a UserError if there are too many nodes
        if ($numNodes > $maxNodes) {
            throw new UserError("GraphQL query body must not be longer than $maxNodes nodes.");
        }
    }
    private static $has_one = [
        'Parent' => self::class,
    ];

    private static $has_many = [
        'Children' => self::class,
    ];

use GraphQL\Error\Error as GraphQLError;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Validator\Rules\CustomValidationRule;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\Rules\QueryDepth;
use GraphQL\Validator\ValidationContext;
use ReflectionProperty;
    /**
     * @dataProvider provideDefaultDepthLimit
     */
    public function testDefaultDepthLimit(int $queryDepth, int $limit)
    {
        // This global rule should be ignored.
        DocumentValidator::addRule(new QueryDepth(1));

        try {
            $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
            $this->runDepthLimitTest($queryDepth, $limit, $schema);
        } finally {
            $this->removeDocumentValidatorRule(QueryDepth::class);
        }
    }

    public function provideDefaultDepthLimit()
    {
        return $this->createProviderForComplexityOrDepth(15);
    }

    /**
     * @dataProvider provideCustomDepthLimit
     */
    public function testCustomDepthLimit(int $queryDepth, int $limit)
    {
        // This global rule should be ignored.
        DocumentValidator::addRule(new QueryDepth(1));

        try {
            $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
            $this->runDepthLimitTest($queryDepth, $limit, $schema);
        } finally {
            $this->removeDocumentValidatorRule(QueryDepth::class);
        }
    }

    public function provideCustomDepthLimit()
    {
        return $this->createProviderForComplexityOrDepth(25);
    }

    /**
     * @dataProvider provideCustomComplexityLimit
     */
    public function testCustomComplexityLimit(int $queryComplexity, int $limit)
    {
        // This global rule should be ignored.
        DocumentValidator::addRule(new QueryComplexity(1));

        try {
            $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
            $this->runComplexityLimitTest($queryComplexity, $limit, $schema);
        } finally {
            $this->removeDocumentValidatorRule(QueryComplexity::class);
        }
    }

    public function provideCustomComplexityLimit()
    {
        return $this->createProviderForComplexityOrDepth(10);
    }

    /**
     * @dataProvider provideDefaultNodeLimit
     */
    public function testDefaultNodeLimit(int $numNodes, int $limit)
    {
        $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
        $this->runNodeLimitTest($numNodes, $limit, $schema);
    }

    public function provideDefaultNodeLimit()
    {
        return $this->createProviderForComplexityOrDepth(500);
    }

    /**
     * @dataProvider provideCustomNodeLimit
     */
    public function testCustomNodeLimit(int $numNodes, int $limit)
    {
        $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
        $this->runNodeLimitTest($numNodes, $limit, $schema);
    }

    public function provideCustomNodeLimit()
    {
        return $this->createProviderForComplexityOrDepth(200);
    }

    public function testGlobalRuleNotRemoved()
    {
        // This global rule should NOT be ignored.
        DocumentValidator::addRule(new CustomValidationRule('never-passes', function (ValidationContext $context) {
            $context->reportError(new GraphQLError('This is the custom rule'));
            return [];
        }));

        try {
            $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__]));
            $result = $this->querySchema($schema, $this->craftRecursiveQuery(15));
            $this->assertFailure($result);
            $this->assertErrorMatchingRegex($result, '/^This is the custom rule$/');
        } finally {
            $this->removeDocumentValidatorRule('never-passes');
        }
    }

    private function removeDocumentValidatorRule(string $ruleName): void
    {
        $reflectionRules = new ReflectionProperty(DocumentValidator::class, 'rules');
        $reflectionRules->setAccessible(true);
        $rules = $reflectionRules->getValue();
        unset($rules[$ruleName]);
        $reflectionRules->setValue($rules);
    }

    private function createProviderForComplexityOrDepth(int $limit): array
    {
        return [
            'far less than limit' => [1, $limit],
            'one less than limit' => [$limit - 1, $limit],
            'exactly at the limit' => [$limit, $limit],
            'one more than limit' => [$limit + 1, $limit],
            'far more than limit' => [$limit + 25, $limit],
        ];
    }

    private function runDepthLimitTest(int $queryDepth, int $maxDepth, Schema $schema): void
    {
        $result = $this->querySchema($schema, $this->craftRecursiveQuery($queryDepth));
        if ($queryDepth > $maxDepth) {
            $this->assertFailure($result);
            $this->assertErrorMatchingRegex($result, '/^Max query depth should be ' . $maxDepth . ' but got ' . $queryDepth . '\.$/');
        } else {
            // Note that the depth limit is based on the depth of the QUERY, not of the RESULTS, so all we really care about
            // is that the query was successful, not what the results were.
            $this->assertSuccess($result);
        }
    }

    private function runComplexityLimitTest(int $queryComplexity, int $maxComplexity, Schema $schema): void
    {
        $result = $this->querySchema($schema, $this->craftComplexQuery($queryComplexity));
        if ($queryComplexity > $maxComplexity) {
            $this->assertFailure($result);
            $this->assertErrorMatchingRegex($result, '/^Max query complexity should be ' . $maxComplexity . ' but got ' . $queryComplexity . '\.$/');
        } else {
            // Note that the complexity limit is based on the complexity of the QUERY, not of the RESULTS, so all we really care about
            // is that the query was successful, not what the results were.
            $this->assertSuccess($result);
        }
    }

    private function runNodeLimitTest(int $queryNodeCount, int $maxNodes, Schema $schema): void
    {
        $result = $this->querySchema($schema, $this->craftComplexQuery($queryNodeCount - 1));
        if ($queryNodeCount > $maxNodes) {
            $this->assertFailure($result);
            $this->assertErrorMatchingRegex($result, '/^GraphQL query body must not be longer than ' . $maxNodes . ' nodes\.$/');
        } else {
            // Note that the complexity limit is based on the complexity of the QUERY, not of the RESULTS, so all we really care about
            // is that the query was successful, not what the results were.
            $this->assertSuccess($result);
        }
    }

    private function craftRecursiveQuery(int $queryDepth): string
    {
        $query = 'query{ readFakeSiteTrees { nodes {';

        for ($i = 0; $i < $queryDepth; $i++) {
            if ($i % 3 === 0) {
                $query .= 'id title';
            } elseif ($i % 3 === 1) {
                $query .= ' parent {';
            } elseif ($i % 3 === 2) {
                if ($i === $queryDepth - 1) {
                    $query .= 'id title';
                } else {
                    $query .= 'id title children { nodes {';
                }
            }
        }

        $endsWith = strrpos($query, 'id title') === strlen($query) - strlen('id title');
        $query .= $endsWith ? '' : 'id title';
        // Add all of the closing brackets
        $numChars = array_count_values(str_split($query));
        for ($i = 0; $i < $numChars['{']; $i++) {
            $query .= '}';
        }

        return $query;
    }

    private function craftComplexQuery(int $queryComplexity): string
    {
        $query = 'query{ readOneFakeSiteTree { id';

        // skip the first two complexity, because those are taken up by "readOneFakeSiteTree { id" above
        for ($i = 0; $i < $queryComplexity - 2; $i++) {
            $query .= ' id';
        }
        // Add all of the closing brackets
        $numChars = array_count_values(str_split($query));
        for ($i = 0; $i < $numChars['{']; $i++) {
            $query .= '}';
        }

        return $query;
    }

        $this->assertEmpty($errors, 'Failed to assert successful query. Got errors: ' . json_encode($errors, JSON_PRETTY_PRINT));
        $this->assertFalse((bool) $error, 'Failed to assert successful query. Got error: ' . $error);
        $errors = $result['errors'] ?? $result['error'] ?? [];
        $this->assertErrorMatchingRegex(
            $result,
            '/^Cannot query field "' . $fieldName . '"/',
            'Failed to assert that result was missing field "' . $fieldName . '"'
        );
    }

    private function assertErrorMatchingRegex(
        array $result,
        string $errorRegex,
        string $message = 'Failed to assert that expected error was present.'
    ) {
        if (isset($result['error'])) {
            $errors[] = ['message' => $result['error']];
        }
        $errorMessages = [];
        $foundError = false;
            if (!isset($error['message'])) {
                continue;
            if (preg_match($errorRegex, $error['message'])) {
                $foundError = true;
                break;
            }
            $errorMessages[] = '"' . $error['message'] . '"';
        $this->assertTrue(
            $foundError,
            $message . ' Regex was: ' . $errorRegex . ', Errors were: ' . implode(', ', $errorMessages)
        );
max_query_complexity: 10
SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
  fields: '*'
  operations: '*'
# this just makes it easier to craft the recursive query programatically
modelConfig:
  DataObject:
    nested_query_plugins:
      paginateList: true

max_query_depth: 25
SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
  fields: '*'
  operations: '*'
max_query_nodes: 200
SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
  fields: '*'
  operations: '*'
# this just makes it easier to craft the recursive query programatically
modelConfig:
  DataObject:
    nested_query_plugins:
      paginateList: true
SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
  fields: '*'
  operations: '*'
SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
  fields: '*'
  operations: '*'
# this just makes it easier to craft the recursive query programatically
modelConfig:
  DataObject:
    nested_query_plugins:
      paginateList: true
SilverStripe\GraphQL\Tests\Fake\FakeSiteTree:
  fields: '*'
  operations: '*'