CVE-2023-40180 (YAML, published 2023-10-16T19:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: 7.5)
Weakness: CWE-400 \u2014 Uncontrolled Resource Consumption
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.
Repository: https://github.com/silverstripe/silverstripe-graphql
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 message: [CVE-2023-40180] Add protection against recursive queries (#558)



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: '*'