Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[CVE-2023-40180] Add protection against recursive queries #557

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ composer require silverstripe/graphql
- [CSRF tokens (required for mutations)](#csrf-tokens-required-for-mutations)
- [Cross-Origin Resource Sharing (CORS)](#cross-origin-resource-sharing-cors)
- [Sample Custom CORS Config](#sample-custom-cors-config)
- [Persisting Queries](#persisting-queries)
- [Recursive or complex queries](#recursive-or-complex-queries)
- [Persisting Queries](#persisting-queries)
- [Schema introspection](#schema-introspection)
- [Setting up a new GraphQL schema](#setting-up-a-new-graphql-schema)
- [Strict HTTP Method Checking](#strict-http-method-checking)
Expand Down Expand Up @@ -2391,8 +2392,42 @@ SilverStripe\Core\Injector\Injector:
properties:
corsConfig:
Enabled: false
```
```

## Recursive or complex queries

GraphQL schemas can contain recursive types and circular dependencies. Recursive or overly complex queries can take up a lot of resources,
and could have a high impact on server performance and even result in a denial of service if not handled carefully.

Before parsing queries, if a query is found to have more than 500 nodes, it is rejected.

While executing queries, there is a default query depth limit of 15 for all schemas, and no current complexity limit.

For calculating the query complexity, every field in the query gets a default score 1 (including ObjectType nodes). Total complexity of the query is the sum of all field scores.

You can customise the node limit and query depth and complexity limits by setting the following configuration:

**app/_config/graphql.yml**

```yaml
SilverStripe\GraphQL\Manager:
default_max_query_nodes: 250 # default 500
default_max_query_depth: 20 # default 15
default_max_query_complexity: 100 # default unlimited
```

You can also configure these settings for individual schemas. This allows you to fine-tune the security of your custom public-facing schema without affecting the security of the schema used in the CMS. To do so, set the values for your schema like so:

**app/_config/graphql.yml**

```yaml
SilverStripe\GraphQL\Manager:
schemas:
default:
max_query_nodes: 250
max_query_depth: 20
max_query_complexity: 100
```

## Persisting queries

Expand Down
116 changes: 115 additions & 1 deletion src/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
use SilverStripe\Security\Security;
use BadMethodCallException;
use Exception;
use GraphQL\Language\Lexer;
use GraphQL\Language\Source;
use GraphQL\Language\Token;
use GraphQL\Utils\Utils;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\Rules\QueryDepth;

/**
* Manager is the master container for a graphql endpoint, and contains
Expand All @@ -47,6 +54,36 @@ class Manager implements ConfigurationApplier

const TYPES_ROOT = 'types';

/**
* Default maximum query nodes if not defined in the current schema config
*/
private static $default_max_query_nodes = 500;

/**
* Default maximum query depth if not defined in the current schema config
*/
private static $default_max_query_depth = 15;

/**
* Default maximum query complexity if not defined in the current schema config
*/
private static $default_max_query_complexity = 0;

/**
* Maximum query nodes allowed for the current schema config
*/
private ?int $maxQueryNodes = null;

/**
* Maximum query depth allowed for the current schema config
*/
private ?int $maxQueryDepth = null;

/**
* Maximum query complexity allowed for the current schema config
*/
private ?int $maxQueryComplexity = null;

/**
* @var string
*/
Expand Down Expand Up @@ -204,6 +241,17 @@ public function applyConfig(array $config)
{
$this->extend('updateConfig', $config);

// Security validation rules
if (array_key_exists('max_query_nodes', $config)) {
$this->maxQueryNodes = $config['max_query_nodes'];
}
if (array_key_exists('max_query_depth', $config)) {
$this->maxQueryDepth = $config['max_query_depth'];
}
if (array_key_exists('max_query_complexity', $config)) {
$this->maxQueryComplexity = $config['max_query_complexity'];
}

// Bootstrap schema class mapping from config
if (array_key_exists('typeNames', $config ?? [])) {
StaticSchema::inst()->setTypeNames($config['typeNames']);
Expand Down Expand Up @@ -372,12 +420,78 @@ public function queryAndReturnResult($query, $params = [])
$context = $this->getContext();

$last = function ($schema, $query, $context, $params) {
return GraphQL::executeQuery($schema, $query, null, $context, $params);
if (is_string($query)) {
$this->validateQueryBeforeParsing($query, $context);
}

$validationRules = DocumentValidator::allRules();
$maxDepth = $this->getMaxQueryDepth();
$maxComplexity = $this->getMaxQueryComplexity();
if ($maxDepth) {
$validationRules[QueryDepth::class] = new QueryDepth($maxDepth);
}
if ($maxComplexity) {
$validationRules[QueryComplexity::class] = new QueryComplexity($maxComplexity);
}
return GraphQL::executeQuery($schema, $query, null, $context, $params, null, null, $validationRules);
};

return $this->callMiddleware($schema, $query, $context, $params, $last);
}

/**
* Validate a query before parsing it in case there are issues we can catch early.
*/
private function validateQueryBeforeParsing(string $query): void
{
$maxNodes = $this->getMaxQueryNodes();

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 GraphQL Invariant violation if there are too many nodes
Utils::invariant(
$maxNodes >= $numNodes,
"GraphQL query body must not be longer than $maxNodes nodes."
);
}

private function getMaxQueryNodes()
{
if ($this->maxQueryNodes !== null) {
return $this->maxQueryNodes;
}
return static::config()->get('default_max_query_nodes');
}

private function getMaxQueryDepth()
{
if ($this->maxQueryDepth !== null) {
return $this->maxQueryDepth;
}
return static::config()->get('default_max_query_depth');
}

private function getMaxQueryComplexity()
{
if ($this->maxQueryComplexity !== null) {
return $this->maxQueryComplexity;
}
return static::config()->get('default_max_query_complexity');
}

/**
* Register a new type
*
Expand Down
Loading
Loading