Skip to content

Commit

Permalink
Add Complexity and Depth Query Security
Browse files Browse the repository at this point in the history
  • Loading branch information
mcg-web committed Apr 9, 2016
1 parent 1bc5e0c commit 653c84a
Show file tree
Hide file tree
Showing 11 changed files with 1,030 additions and 6 deletions.
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,42 @@ header('Content-Type: application/json');
echo json_encode($result);
```

### Security

#### Query Complexity Analysis

This is a PHP port of [Query Complexity Analysis](http://sangria-graphql.org/learn/#query-complexity-analysis) in Sangria implementation.
Introspection query with description max complexity is **109**.

This document validator rule is disabled by default. Here an example to enabled it:

```php
use GraphQL\GraphQL;

/** @var \GraphQL\Validator\Rules\QueryComplexity $queryComplexity */
$queryComplexity = DocumentValidator::getRule('QueryComplexity');
$queryComplexity->setMaxQueryDepth($maxQueryDepth = 110);

GraphQL::execute(/*...*/);
```

#### Limiting Query Depth

This is a PHP port of [Limiting Query Depth](http://sangria-graphql.org/learn/#limiting-query-depth) in Sangria implementation.
Introspection query with description max depth is **7**.

This document validator rule is disabled by default. Here an example to enabled it:

```php
use GraphQL\GraphQL;

/** @var \GraphQL\Validator\Rules\QueryDepth $queryDepth */
$queryDepth = DocumentValidator::getRule('QueryDepth');
$queryDepth->setMaxQueryDepth($maxQueryDepth = 10);

GraphQL::execute(/*...*/);
```

### More Examples
Make sure to check [tests](https://github.com/webonyx/graphql-php/tree/master/tests) for more usage examples.

Expand Down
6 changes: 6 additions & 0 deletions src/GraphQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use GraphQL\Language\Parser;
use GraphQL\Language\Source;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Validator\Rules\QueryComplexity;

class GraphQL
{
Expand Down Expand Up @@ -35,6 +36,11 @@ public static function executeAndReturnResult(Schema $schema, $requestString, $r
try {
$source = new Source($requestString ?: '', 'GraphQL request');
$documentAST = Parser::parse($source);

/** @var QueryComplexity $queryComplexity */
$queryComplexity = DocumentValidator::getRule('QueryComplexity');
$queryComplexity->setRawVariableValues($variableValues);

$validationErrors = DocumentValidator::validate($schema, $documentAST);

if (!empty($validationErrors)) {
Expand Down
18 changes: 18 additions & 0 deletions src/Type/Definition/FieldDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

class FieldDefinition
{
const DEFAULT_COMPLEXITY_FN = 'GraphQL\Type\Definition\FieldDefinition::defaultComplexity';

/**
* @var string
*/
Expand Down Expand Up @@ -72,6 +74,7 @@ public static function getDefinition()
'map' => Config::CALLBACK,
'description' => Config::STRING,
'deprecationReason' => Config::STRING,
'complexity' => Config::CALLBACK,
]);
}

Expand Down Expand Up @@ -113,6 +116,8 @@ protected function __construct(array $config)
$this->deprecationReason = isset($config['deprecationReason']) ? $config['deprecationReason'] : null;

$this->config = $config;

$this->complexityFn = isset($config['complexity']) ? $config['complexity'] : static::DEFAULT_COMPLEXITY_FN;
}

/**
Expand Down Expand Up @@ -141,4 +146,17 @@ public function getType()
}
return $this->resolvedType;
}

/**
* @return callable|\Closure
*/
public function getComplexityFn()
{
return $this->complexityFn;
}

public static function defaultComplexity($childrenComplexity)
{
return $childrenComplexity + 1;
}
}
14 changes: 8 additions & 6 deletions src/Validator/DocumentValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
use GraphQL\Validator\Rules\OverlappingFieldsCanBeMerged;
use GraphQL\Validator\Rules\PossibleFragmentSpreads;
use GraphQL\Validator\Rules\ProvidedNonNullArguments;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\Rules\QueryDepth;
use GraphQL\Validator\Rules\ScalarLeafs;
use GraphQL\Validator\Rules\VariablesAreInputTypes;
use GraphQL\Validator\Rules\VariablesInAllowedPosition;
Expand Down Expand Up @@ -82,6 +84,9 @@ public static function defaultRules()
'DefaultValuesOfCorrectType' => new DefaultValuesOfCorrectType(),
'VariablesInAllowedPosition' => new VariablesInAllowedPosition(),
'OverlappingFieldsCanBeMerged' => new OverlappingFieldsCanBeMerged(),
// Query Security
'QueryDepth' => new QueryDepth(QueryDepth::DISABLED), // default disabled
'QueryComplexity' => new QueryComplexity(QueryComplexity::DISABLED), // default disabled
];
}

Expand All @@ -90,19 +95,16 @@ public static function defaultRules()

public static function getRule($name)
{
return isset(self::$rules[$name]) ? self::$rules[$name] : null ;
$rules = static::allRules();

return isset($rules[$name]) ? $rules[$name] : null ;
}

public static function addRule($name, callable $rule)
{
self::$rules[$name] = $rule;
}

public static function removeRule($name)
{
unset(self::$rules[$name]);
}

public static function validate(Schema $schema, Document $ast, array $rules = null)
{
$errors = static::visitUsingRules($schema, $ast, $rules ?: static::allRules());
Expand Down
171 changes: 171 additions & 0 deletions src/Validator/Rules/AbstractQuerySecurity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php

namespace GraphQL\Validator\Rules;

use GraphQL\Language\AST\Field;
use GraphQL\Language\AST\FragmentDefinition;
use GraphQL\Language\AST\FragmentSpread;
use GraphQL\Language\AST\InlineFragment;
use GraphQL\Language\AST\Node;
use GraphQL\Language\AST\SelectionSet;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Introspection;
use GraphQL\Utils\TypeInfo;
use GraphQL\Validator\ValidationContext;

abstract class AbstractQuerySecurity
{
const DISABLED = 0;

/** @var FragmentDefinition[] */
private $fragments = [];

/**
* @return \GraphQL\Language\AST\FragmentDefinition[]
*/
protected function getFragments()
{
return $this->fragments;
}

/**
* check if equal to 0 no check is done. Must be greater or equal to 0.
*
* @param $value
*/
protected function checkIfGreaterOrEqualToZero($name, $value)
{
if ($value < 0) {
throw new \InvalidArgumentException(sprintf('$%s argument must be greater or equal to 0.', $name));
}
}

protected function gatherFragmentDefinition(ValidationContext $context)
{
// Gather all the fragment definition.
// Importantly this does not include inline fragments.
$definitions = $context->getDocument()->definitions;
foreach ($definitions as $node) {
if ($node instanceof FragmentDefinition) {
$this->fragments[$node->name->value] = $node;
}
}
}

protected function getFragment(FragmentSpread $fragmentSpread)
{
$spreadName = $fragmentSpread->name->value;
$fragments = $this->getFragments();

return isset($fragments[$spreadName]) ? $fragments[$spreadName] : null;
}

protected function invokeIfNeeded(ValidationContext $context, array $validators)
{
// is disabled?
if (!$this->isEnabled()) {
return [];
}

$this->gatherFragmentDefinition($context);

return $validators;
}

/**
* Given a selectionSet, adds all of the fields in that selection to
* the passed in map of fields, and returns it at the end.
*
* Note: This is not the same as execution's collectFields because at static
* time we do not know what object type will be used, so we unconditionally
* spread in all fragments.
*
* @see GraphQL\Validator\Rules\OverlappingFieldsCanBeMerged
*
* @param ValidationContext $context
* @param Type|null $parentType
* @param SelectionSet $selectionSet
* @param \ArrayObject $visitedFragmentNames
* @param \ArrayObject $astAndDefs
*
* @return \ArrayObject
*/
protected function collectFieldASTsAndDefs(ValidationContext $context, $parentType, SelectionSet $selectionSet, \ArrayObject $visitedFragmentNames = null, \ArrayObject $astAndDefs = null)
{
$_visitedFragmentNames = $visitedFragmentNames ?: new \ArrayObject();
$_astAndDefs = $astAndDefs ?: new \ArrayObject();

foreach ($selectionSet->selections as $selection) {
switch ($selection->kind) {
case Node::FIELD:
/* @var Field $selection */
$fieldName = $selection->name->value;
$fieldDef = null;
if ($parentType && method_exists($parentType, 'getFields')) {
$tmp = $parentType->getFields();
$schemaMetaFieldDef = Introspection::schemaMetaFieldDef();
$typeMetaFieldDef = Introspection::typeMetaFieldDef();
$typeNameMetaFieldDef = Introspection::typeNameMetaFieldDef();

if ($fieldName === $schemaMetaFieldDef->name && $context->getSchema()->getQueryType() === $parentType) {
$fieldDef = $schemaMetaFieldDef;
} elseif ($fieldName === $typeMetaFieldDef->name && $context->getSchema()->getQueryType() === $parentType) {
$fieldDef = $typeMetaFieldDef;
} elseif ($fieldName === $typeNameMetaFieldDef->name) {
$fieldDef = $typeNameMetaFieldDef;
} elseif (isset($tmp[$fieldName])) {
$fieldDef = $tmp[$fieldName];
}
}
$responseName = $this->getFieldName($selection);
if (!isset($_astAndDefs[$responseName])) {
$_astAndDefs[$responseName] = new \ArrayObject();
}
// create field context
$_astAndDefs[$responseName][] = [$selection, $fieldDef];
break;
case Node::INLINE_FRAGMENT:
/* @var InlineFragment $selection */
$_astAndDefs = $this->collectFieldASTsAndDefs(
$context,
TypeInfo::typeFromAST($context->getSchema(), $selection->typeCondition),
$selection->selectionSet,
$_visitedFragmentNames,
$_astAndDefs
);
break;
case Node::FRAGMENT_SPREAD:
/* @var FragmentSpread $selection */
$fragName = $selection->name->value;

if (empty($_visitedFragmentNames[$fragName])) {
$_visitedFragmentNames[$fragName] = true;
$fragment = $context->getFragment($fragName);

if ($fragment) {
$_astAndDefs = $this->collectFieldASTsAndDefs(
$context,
TypeInfo::typeFromAST($context->getSchema(), $fragment->typeCondition),
$fragment->selectionSet,
$_visitedFragmentNames,
$_astAndDefs
);
}
}
break;
}
}

return $_astAndDefs;
}

protected function getFieldName(Field $node)
{
$fieldName = $node->name->value;
$responseName = $node->alias ? $node->alias->value : $fieldName;

return $responseName;
}

abstract protected function isEnabled();
}
Loading

0 comments on commit 653c84a

Please sign in to comment.