mirror of
https://github.com/retailcrm/graphql-php.git
synced 2024-11-21 20:36:05 +03:00
Add Complexity and Depth Query Security
This commit is contained in:
parent
1bc5e0c9da
commit
545fe616a0
36
README.md
36
README.md
@ -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->setMaxQueryComplexity($maxQueryComplexity = 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.
|
||||
|
||||
|
@ -6,6 +6,7 @@ use GraphQL\Executor\Executor;
|
||||
use GraphQL\Language\Parser;
|
||||
use GraphQL\Language\Source;
|
||||
use GraphQL\Validator\DocumentValidator;
|
||||
use GraphQL\Validator\Rules\QueryComplexity;
|
||||
|
||||
class GraphQL
|
||||
{
|
||||
@ -35,6 +36,11 @@ class GraphQL
|
||||
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)) {
|
||||
|
@ -5,6 +5,8 @@ use GraphQL\Utils;
|
||||
|
||||
class FieldDefinition
|
||||
{
|
||||
const DEFAULT_COMPLEXITY_FN = 'GraphQL\Type\Definition\FieldDefinition::defaultComplexity';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
@ -72,6 +74,7 @@ class FieldDefinition
|
||||
'map' => Config::CALLBACK,
|
||||
'description' => Config::STRING,
|
||||
'deprecationReason' => Config::STRING,
|
||||
'complexity' => Config::CALLBACK,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -113,6 +116,8 @@ class FieldDefinition
|
||||
$this->deprecationReason = isset($config['deprecationReason']) ? $config['deprecationReason'] : null;
|
||||
|
||||
$this->config = $config;
|
||||
|
||||
$this->complexityFn = isset($config['complexity']) ? $config['complexity'] : static::DEFAULT_COMPLEXITY_FN;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -141,4 +146,17 @@ class FieldDefinition
|
||||
}
|
||||
return $this->resolvedType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return callable|\Closure
|
||||
*/
|
||||
public function getComplexityFn()
|
||||
{
|
||||
return $this->complexityFn;
|
||||
}
|
||||
|
||||
public static function defaultComplexity($childrenComplexity)
|
||||
{
|
||||
return $childrenComplexity + 1;
|
||||
}
|
||||
}
|
||||
|
@ -34,6 +34,8 @@ use GraphQL\Validator\Rules\NoUnusedVariables;
|
||||
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;
|
||||
@ -82,6 +84,9 @@ class DocumentValidator
|
||||
'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
|
||||
];
|
||||
}
|
||||
|
||||
@ -90,7 +95,9 @@ class DocumentValidator
|
||||
|
||||
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)
|
||||
@ -98,11 +105,6 @@ class DocumentValidator
|
||||
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());
|
||||
|
171
src/Validator/Rules/AbstractQuerySecurity.php
Normal file
171
src/Validator/Rules/AbstractQuerySecurity.php
Normal file
@ -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();
|
||||
}
|
212
src/Validator/Rules/QueryComplexity.php
Normal file
212
src/Validator/Rules/QueryComplexity.php
Normal file
@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace GraphQL\Validator\Rules;
|
||||
|
||||
use GraphQL\Error;
|
||||
use GraphQL\Executor\Values;
|
||||
use GraphQL\Language\AST\Field;
|
||||
use GraphQL\Language\AST\FragmentSpread;
|
||||
use GraphQL\Language\AST\InlineFragment;
|
||||
use GraphQL\Language\AST\Node;
|
||||
use GraphQL\Language\AST\OperationDefinition;
|
||||
use GraphQL\Language\AST\SelectionSet;
|
||||
use GraphQL\Language\Visitor;
|
||||
use GraphQL\Type\Definition\FieldDefinition;
|
||||
use GraphQL\Validator\ValidationContext;
|
||||
|
||||
class QueryComplexity extends AbstractQuerySecurity
|
||||
{
|
||||
private $maxQueryComplexity;
|
||||
|
||||
private $rawVariableValues = [];
|
||||
|
||||
private $variableDefs;
|
||||
|
||||
private $fieldAstAndDefs;
|
||||
|
||||
/**
|
||||
* @var ValidationContext
|
||||
*/
|
||||
private $context;
|
||||
|
||||
public function __construct($maxQueryDepth)
|
||||
{
|
||||
$this->setMaxQueryComplexity($maxQueryDepth);
|
||||
}
|
||||
|
||||
public static function maxQueryComplexityErrorMessage($max, $count)
|
||||
{
|
||||
return sprintf('Max query complexity should be %d but got %d.', $max, $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set max query complexity. If equal to 0 no check is done. Must be greater or equal to 0.
|
||||
*
|
||||
* @param $maxQueryComplexity
|
||||
*/
|
||||
public function setMaxQueryComplexity($maxQueryComplexity)
|
||||
{
|
||||
$this->checkIfGreaterOrEqualToZero('maxQueryComplexity', $maxQueryComplexity);
|
||||
|
||||
$this->maxQueryComplexity = (int) $maxQueryComplexity;
|
||||
}
|
||||
|
||||
public function getMaxQueryComplexity()
|
||||
{
|
||||
return $this->maxQueryComplexity;
|
||||
}
|
||||
|
||||
public function setRawVariableValues(array $rawVariableValues = null)
|
||||
{
|
||||
$this->rawVariableValues = $rawVariableValues ?: [];
|
||||
}
|
||||
|
||||
public function getRawVariableValues()
|
||||
{
|
||||
return $this->rawVariableValues;
|
||||
}
|
||||
|
||||
public function __invoke(ValidationContext $context)
|
||||
{
|
||||
$this->context = $context;
|
||||
|
||||
$this->variableDefs = new \ArrayObject();
|
||||
$this->fieldAstAndDefs = new \ArrayObject();
|
||||
$complexity = 0;
|
||||
|
||||
return $this->invokeIfNeeded(
|
||||
$context,
|
||||
[
|
||||
// Visit FragmentDefinition after visiting FragmentSpread
|
||||
'visitSpreadFragments' => true,
|
||||
Node::SELECTION_SET => function (SelectionSet $selectionSet) use ($context) {
|
||||
$this->fieldAstAndDefs = $this->collectFieldASTsAndDefs(
|
||||
$context,
|
||||
$context->getParentType(),
|
||||
$selectionSet,
|
||||
null,
|
||||
$this->fieldAstAndDefs
|
||||
);
|
||||
},
|
||||
Node::VARIABLE_DEFINITION => function ($def) {
|
||||
$this->variableDefs[] = $def;
|
||||
|
||||
return Visitor::skipNode();
|
||||
},
|
||||
Node::OPERATION_DEFINITION => [
|
||||
'leave' => function (OperationDefinition $operationDefinition) use ($context, &$complexity) {
|
||||
$complexity = $this->fieldComplexity($operationDefinition, $complexity);
|
||||
|
||||
if ($complexity > $this->getMaxQueryComplexity()) {
|
||||
return new Error($this->maxQueryComplexityErrorMessage($this->getMaxQueryComplexity(), $complexity));
|
||||
}
|
||||
},
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function fieldComplexity($node, $complexity = 0)
|
||||
{
|
||||
if (isset($node->selectionSet) && $node->selectionSet instanceof SelectionSet) {
|
||||
foreach ($node->selectionSet->selections as $childNode) {
|
||||
$complexity = $this->nodeComplexity($childNode, $complexity);
|
||||
}
|
||||
}
|
||||
|
||||
return $complexity;
|
||||
}
|
||||
|
||||
private function nodeComplexity(Node $node, $complexity = 0)
|
||||
{
|
||||
switch ($node->kind) {
|
||||
case Node::FIELD:
|
||||
/* @var Field $node */
|
||||
// default values
|
||||
$args = [];
|
||||
$complexityFn = FieldDefinition::DEFAULT_COMPLEXITY_FN;
|
||||
|
||||
// calculate children complexity if needed
|
||||
$childrenComplexity = 0;
|
||||
|
||||
// node has children?
|
||||
if (isset($node->selectionSet)) {
|
||||
$childrenComplexity = $this->fieldComplexity($node);
|
||||
}
|
||||
|
||||
$astFieldInfo = $this->astFieldInfo($node);
|
||||
$fieldDef = $astFieldInfo[1];
|
||||
|
||||
if ($fieldDef instanceof FieldDefinition) {
|
||||
$args = $this->buildFieldArguments($node);
|
||||
//get complexity fn using fieldDef complexity
|
||||
if (method_exists($fieldDef, 'getComplexityFn')) {
|
||||
$complexityFn = $fieldDef->getComplexityFn();
|
||||
}
|
||||
}
|
||||
|
||||
$complexity += call_user_func_array($complexityFn, [$childrenComplexity, $args]);
|
||||
break;
|
||||
|
||||
case Node::INLINE_FRAGMENT:
|
||||
/* @var InlineFragment $node */
|
||||
// node has children?
|
||||
if (isset($node->selectionSet)) {
|
||||
$complexity = $this->fieldComplexity($node, $complexity);
|
||||
}
|
||||
break;
|
||||
|
||||
case Node::FRAGMENT_SPREAD:
|
||||
/* @var FragmentSpread $node */
|
||||
$fragment = $this->getFragment($node);
|
||||
|
||||
if (null !== $fragment) {
|
||||
$complexity = $this->fieldComplexity($fragment, $complexity);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $complexity;
|
||||
}
|
||||
|
||||
private function astFieldInfo(Field $field)
|
||||
{
|
||||
$fieldName = $this->getFieldName($field);
|
||||
$astFieldInfo = [null, null];
|
||||
if (isset($this->fieldAstAndDefs[$fieldName])) {
|
||||
foreach ($this->fieldAstAndDefs[$fieldName] as $astAndDef) {
|
||||
if ($astAndDef[0] == $field) {
|
||||
$astFieldInfo = $astAndDef;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $astFieldInfo;
|
||||
}
|
||||
|
||||
private function buildFieldArguments(Field $node)
|
||||
{
|
||||
$rawVariableValues = $this->getRawVariableValues();
|
||||
$astFieldInfo = $this->astFieldInfo($node);
|
||||
$fieldDef = $astFieldInfo[1];
|
||||
|
||||
$args = [];
|
||||
|
||||
if ($fieldDef instanceof FieldDefinition) {
|
||||
$variableValues = Values::getVariableValues(
|
||||
$this->context->getSchema(),
|
||||
$this->variableDefs,
|
||||
$rawVariableValues
|
||||
);
|
||||
$args = Values::getArgumentValues($fieldDef->args, $node->arguments, $variableValues);
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
protected function isEnabled()
|
||||
{
|
||||
return $this->getMaxQueryComplexity() !== static::DISABLED;
|
||||
}
|
||||
}
|
116
src/Validator/Rules/QueryDepth.php
Normal file
116
src/Validator/Rules/QueryDepth.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
namespace GraphQL\Validator\Rules;
|
||||
|
||||
use GraphQL\Error;
|
||||
use GraphQL\Language\AST\Field;
|
||||
use GraphQL\Language\AST\FragmentSpread;
|
||||
use GraphQL\Language\AST\InlineFragment;
|
||||
use GraphQL\Language\AST\Node;
|
||||
use GraphQL\Language\AST\OperationDefinition;
|
||||
use GraphQL\Language\AST\SelectionSet;
|
||||
use GraphQL\Validator\ValidationContext;
|
||||
|
||||
class QueryDepth extends AbstractQuerySecurity
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $maxQueryDepth;
|
||||
|
||||
public function __construct($maxQueryDepth)
|
||||
{
|
||||
$this->setMaxQueryDepth($maxQueryDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set max query depth. If equal to 0 no check is done. Must be greater or equal to 0.
|
||||
*
|
||||
* @param $maxQueryDepth
|
||||
*/
|
||||
public function setMaxQueryDepth($maxQueryDepth)
|
||||
{
|
||||
$this->checkIfGreaterOrEqualToZero('maxQueryDepth', $maxQueryDepth);
|
||||
|
||||
$this->maxQueryDepth = (int) $maxQueryDepth;
|
||||
}
|
||||
|
||||
public function getMaxQueryDepth()
|
||||
{
|
||||
return $this->maxQueryDepth;
|
||||
}
|
||||
|
||||
public static function maxQueryDepthErrorMessage($max, $count)
|
||||
{
|
||||
return sprintf('Max query depth should be %d but got %d.', $max, $count);
|
||||
}
|
||||
|
||||
public function __invoke(ValidationContext $context)
|
||||
{
|
||||
return $this->invokeIfNeeded(
|
||||
$context,
|
||||
[
|
||||
Node::OPERATION_DEFINITION => [
|
||||
'leave' => function (OperationDefinition $operationDefinition) use ($context) {
|
||||
$maxDepth = $this->fieldDepth($operationDefinition);
|
||||
|
||||
if ($maxDepth > $this->getMaxQueryDepth()) {
|
||||
return new Error($this->maxQueryDepthErrorMessage($this->getMaxQueryDepth(), $maxDepth));
|
||||
}
|
||||
},
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected function isEnabled()
|
||||
{
|
||||
return $this->getMaxQueryDepth() !== static::DISABLED;
|
||||
}
|
||||
|
||||
private function fieldDepth($node, $depth = 0, $maxDepth = 0)
|
||||
{
|
||||
if (isset($node->selectionSet) && $node->selectionSet instanceof SelectionSet) {
|
||||
foreach ($node->selectionSet->selections as $childNode) {
|
||||
$maxDepth = $this->nodeDepth($childNode, $depth, $maxDepth);
|
||||
}
|
||||
}
|
||||
|
||||
return $maxDepth;
|
||||
}
|
||||
|
||||
private function nodeDepth(Node $node, $depth = 0, $maxDepth = 0)
|
||||
{
|
||||
switch ($node->kind) {
|
||||
case Node::FIELD:
|
||||
/* @var Field $node */
|
||||
// node has children?
|
||||
if (null !== $node->selectionSet) {
|
||||
// update maxDepth if needed
|
||||
if ($depth > $maxDepth) {
|
||||
$maxDepth = $depth;
|
||||
}
|
||||
$maxDepth = $this->fieldDepth($node, $depth + 1, $maxDepth);
|
||||
}
|
||||
break;
|
||||
|
||||
case Node::INLINE_FRAGMENT:
|
||||
/* @var InlineFragment $node */
|
||||
// node has children?
|
||||
if (null !== $node->selectionSet) {
|
||||
$maxDepth = $this->fieldDepth($node, $depth, $maxDepth);
|
||||
}
|
||||
break;
|
||||
|
||||
case Node::FRAGMENT_SPREAD:
|
||||
/* @var FragmentSpread $node */
|
||||
$fragment = $this->getFragment($node);
|
||||
|
||||
if (null !== $fragment) {
|
||||
$maxDepth = $this->fieldDepth($fragment, $depth, $maxDepth);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $maxDepth;
|
||||
}
|
||||
}
|
95
tests/Validator/AbstractQuerySecurityTest.php
Normal file
95
tests/Validator/AbstractQuerySecurityTest.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
namespace GraphQL\Tests\Validator;
|
||||
|
||||
use GraphQL\FormattedError;
|
||||
use GraphQL\Language\Parser;
|
||||
use GraphQL\Type\Introspection;
|
||||
use GraphQL\Validator\DocumentValidator;
|
||||
use GraphQL\Validator\Rules\AbstractQuerySecurity;
|
||||
|
||||
abstract class AbstractQuerySecurityTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
/**
|
||||
* @param $max
|
||||
*
|
||||
* @return AbstractQuerySecurity
|
||||
*/
|
||||
abstract protected function getRule($max);
|
||||
|
||||
/**
|
||||
* @param $max
|
||||
* @param $count
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
abstract protected function getErrorMessage($max, $count);
|
||||
|
||||
/**
|
||||
* @expectedException \InvalidArgumentException
|
||||
* @expectedExceptionMessage argument must be greater or equal to 0.
|
||||
*/
|
||||
public function testMaxQueryDepthMustBeGreaterOrEqualTo0()
|
||||
{
|
||||
$this->getRule(-1);
|
||||
}
|
||||
|
||||
protected function createFormattedError($max, $count, $locations = [])
|
||||
{
|
||||
return FormattedError::create($this->getErrorMessage($max, $count), $locations);
|
||||
}
|
||||
|
||||
protected function assertDocumentValidator($queryString, $max, array $expectedErrors = [])
|
||||
{
|
||||
$errors = DocumentValidator::validate(
|
||||
QuerySecuritySchema::buildSchema(),
|
||||
Parser::parse($queryString),
|
||||
[$this->getRule($max)]
|
||||
);
|
||||
|
||||
$this->assertEquals($expectedErrors, array_map(['GraphQL\Error', 'formatError'], $errors), $queryString);
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
protected function assertIntrospectionQuery($maxExpected)
|
||||
{
|
||||
$query = Introspection::getIntrospectionQuery(true);
|
||||
|
||||
$this->assertMaxValue($query, $maxExpected);
|
||||
}
|
||||
|
||||
protected function assertIntrospectionTypeMetaFieldQuery($maxExpected)
|
||||
{
|
||||
$query = '
|
||||
{
|
||||
__type(name: "Human") {
|
||||
name
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$this->assertMaxValue($query, $maxExpected);
|
||||
}
|
||||
|
||||
protected function assertTypeNameMetaFieldQuery($maxExpected)
|
||||
{
|
||||
$query = '
|
||||
{
|
||||
human {
|
||||
__typename
|
||||
firstName
|
||||
}
|
||||
}
|
||||
';
|
||||
$this->assertMaxValue($query, $maxExpected);
|
||||
}
|
||||
|
||||
protected function assertMaxValue($query, $maxExpected)
|
||||
{
|
||||
$this->assertDocumentValidator($query, $maxExpected);
|
||||
$newMax = $maxExpected - 1;
|
||||
if ($newMax !== AbstractQuerySecurity::DISABLED) {
|
||||
$this->assertDocumentValidator($query, $newMax, [$this->createFormattedError($newMax, $maxExpected)]);
|
||||
}
|
||||
}
|
||||
}
|
116
tests/Validator/QueryComplexityTest.php
Normal file
116
tests/Validator/QueryComplexityTest.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
namespace GraphQL\Tests\Validator;
|
||||
|
||||
use GraphQL\Validator\Rules\QueryComplexity;
|
||||
|
||||
class QueryComplexityTest extends AbstractQuerySecurityTest
|
||||
{
|
||||
/** @var QueryComplexity */
|
||||
private static $rule;
|
||||
|
||||
/**
|
||||
* @param $max
|
||||
* @param $count
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getErrorMessage($max, $count)
|
||||
{
|
||||
return QueryComplexity::maxQueryComplexityErrorMessage($max, $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $maxDepth
|
||||
*
|
||||
* @return QueryComplexity
|
||||
*/
|
||||
protected function getRule($maxDepth = null)
|
||||
{
|
||||
if (null === self::$rule) {
|
||||
self::$rule = new QueryComplexity($maxDepth);
|
||||
} elseif (null !== $maxDepth) {
|
||||
self::$rule->setMaxQueryComplexity($maxDepth);
|
||||
}
|
||||
|
||||
return self::$rule;
|
||||
}
|
||||
|
||||
public function testSimpleQueries()
|
||||
{
|
||||
$query = 'query MyQuery { human { firstName } }';
|
||||
|
||||
$this->assertDocumentValidators($query, 2, 3);
|
||||
}
|
||||
|
||||
public function testInlineFragmentQueries()
|
||||
{
|
||||
$query = 'query MyQuery { human { ... on Human { firstName } } }';
|
||||
|
||||
$this->assertDocumentValidators($query, 2, 3);
|
||||
}
|
||||
|
||||
public function testFragmentQueries()
|
||||
{
|
||||
$query = 'query MyQuery { human { ...F1 } } fragment F1 on Human { firstName}';
|
||||
|
||||
$this->assertDocumentValidators($query, 2, 3);
|
||||
}
|
||||
|
||||
public function testAliasesQueries()
|
||||
{
|
||||
$query = 'query MyQuery { thomas: human(name: "Thomas") { firstName } jeremy: human(name: "Jeremy") { firstName } }';
|
||||
|
||||
$this->assertDocumentValidators($query, 4, 5);
|
||||
}
|
||||
|
||||
public function testCustomComplexityQueries()
|
||||
{
|
||||
$query = 'query MyQuery { human { dogs { name } } }';
|
||||
|
||||
$this->assertDocumentValidators($query, 12, 13);
|
||||
}
|
||||
|
||||
public function testCustomComplexityWithArgsQueries()
|
||||
{
|
||||
$query = 'query MyQuery { human { dogs(name: "Root") { name } } }';
|
||||
|
||||
$this->assertDocumentValidators($query, 3, 4);
|
||||
}
|
||||
|
||||
public function testCustomComplexityWithVariablesQueries()
|
||||
{
|
||||
$query = 'query MyQuery($dog: String!) { human { dogs(name: $dog) { name } } }';
|
||||
|
||||
$this->getRule()->setRawVariableValues(['dog' => 'Roots']);
|
||||
|
||||
$this->assertDocumentValidators($query, 3, 4);
|
||||
}
|
||||
|
||||
public function testComplexityIntrospectionQuery()
|
||||
{
|
||||
$this->assertIntrospectionQuery(109);
|
||||
}
|
||||
|
||||
public function testIntrospectionTypeMetaFieldQuery()
|
||||
{
|
||||
$this->assertIntrospectionTypeMetaFieldQuery(2);
|
||||
}
|
||||
|
||||
public function testTypeNameMetaFieldQuery()
|
||||
{
|
||||
$this->assertTypeNameMetaFieldQuery(3);
|
||||
}
|
||||
|
||||
private function assertDocumentValidators($query, $queryComplexity, $startComplexity)
|
||||
{
|
||||
for ($maxComplexity = $startComplexity; $maxComplexity >= 0; --$maxComplexity) {
|
||||
$positions = [];
|
||||
|
||||
if ($maxComplexity < $queryComplexity && $maxComplexity !== QueryComplexity::DISABLED) {
|
||||
$positions = [$this->createFormattedError($maxComplexity, $queryComplexity)];
|
||||
}
|
||||
|
||||
$this->assertDocumentValidator($query, $maxComplexity, $positions);
|
||||
}
|
||||
}
|
||||
}
|
148
tests/Validator/QueryDepthTest.php
Normal file
148
tests/Validator/QueryDepthTest.php
Normal file
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
namespace GraphQL\Tests\Validator;
|
||||
|
||||
use GraphQL\Validator\Rules\QueryDepth;
|
||||
|
||||
class QueryDepthTest extends AbstractQuerySecurityTest
|
||||
{
|
||||
/**
|
||||
* @param $max
|
||||
* @param $count
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getErrorMessage($max, $count)
|
||||
{
|
||||
return QueryDepth::maxQueryDepthErrorMessage($max, $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $maxDepth
|
||||
*
|
||||
* @return QueryDepth
|
||||
*/
|
||||
protected function getRule($maxDepth)
|
||||
{
|
||||
return new QueryDepth($maxDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $queryDepth
|
||||
* @param int $maxQueryDepth
|
||||
* @param array $expectedErrors
|
||||
* @dataProvider queryDataProvider
|
||||
*/
|
||||
public function testSimpleQueries($queryDepth, $maxQueryDepth = 7, $expectedErrors = [])
|
||||
{
|
||||
$this->assertDocumentValidator($this->buildRecursiveQuery($queryDepth), $maxQueryDepth, $expectedErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $queryDepth
|
||||
* @param int $maxQueryDepth
|
||||
* @param array $expectedErrors
|
||||
* @dataProvider queryDataProvider
|
||||
*/
|
||||
public function testFragmentQueries($queryDepth, $maxQueryDepth = 7, $expectedErrors = [])
|
||||
{
|
||||
$this->assertDocumentValidator($this->buildRecursiveUsingFragmentQuery($queryDepth), $maxQueryDepth, $expectedErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $queryDepth
|
||||
* @param int $maxQueryDepth
|
||||
* @param array $expectedErrors
|
||||
* @dataProvider queryDataProvider
|
||||
*/
|
||||
public function testInlineFragmentQueries($queryDepth, $maxQueryDepth = 7, $expectedErrors = [])
|
||||
{
|
||||
$this->assertDocumentValidator($this->buildRecursiveUsingInlineFragmentQuery($queryDepth), $maxQueryDepth, $expectedErrors);
|
||||
}
|
||||
|
||||
public function testComplexityIntrospectionQuery()
|
||||
{
|
||||
$this->assertIntrospectionQuery(7);
|
||||
}
|
||||
|
||||
public function testIntrospectionTypeMetaFieldQuery()
|
||||
{
|
||||
$this->assertIntrospectionTypeMetaFieldQuery(1);
|
||||
}
|
||||
|
||||
public function testTypeNameMetaFieldQuery()
|
||||
{
|
||||
$this->assertTypeNameMetaFieldQuery(1);
|
||||
}
|
||||
|
||||
public function queryDataProvider()
|
||||
{
|
||||
return [
|
||||
[1], // Valid because depth under default limit (7)
|
||||
[2],
|
||||
[3],
|
||||
[4],
|
||||
[5],
|
||||
[6],
|
||||
[7],
|
||||
[8, 9], // Valid because depth under new limit (9)
|
||||
[10, 0], // Valid because 0 depth disable limit
|
||||
[
|
||||
10,
|
||||
8,
|
||||
[$this->createFormattedError(8, 10)],
|
||||
], // failed because depth over limit (8)
|
||||
[
|
||||
20,
|
||||
15,
|
||||
[$this->createFormattedError(15, 20)],
|
||||
], // failed because depth over limit (15)
|
||||
];
|
||||
}
|
||||
|
||||
private function buildRecursiveQuery($depth)
|
||||
{
|
||||
$query = sprintf('query MyQuery { human%s }', $this->buildRecursiveQueryPart($depth));
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function buildRecursiveUsingFragmentQuery($depth)
|
||||
{
|
||||
$query = sprintf(
|
||||
'query MyQuery { human { ...F1 } } fragment F1 on Human %s',
|
||||
$this->buildRecursiveQueryPart($depth)
|
||||
);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function buildRecursiveUsingInlineFragmentQuery($depth)
|
||||
{
|
||||
$query = sprintf(
|
||||
'query MyQuery { human { ...on Human %s } }',
|
||||
$this->buildRecursiveQueryPart($depth)
|
||||
);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function buildRecursiveQueryPart($depth)
|
||||
{
|
||||
$templates = [
|
||||
'human' => ' { firstName%s } ',
|
||||
'dog' => ' dogs { name%s } ',
|
||||
];
|
||||
|
||||
$part = $templates['human'];
|
||||
|
||||
for ($i = 1; $i <= $depth; ++$i) {
|
||||
$key = ($i % 2 == 1) ? 'human' : 'dog';
|
||||
$template = $templates[$key];
|
||||
|
||||
$part = sprintf($part, ('human' == $key ? ' owner ' : '').$template);
|
||||
}
|
||||
$part = str_replace('%s', '', $part);
|
||||
|
||||
return $part;
|
||||
}
|
||||
}
|
104
tests/Validator/QuerySecuritySchema.php
Normal file
104
tests/Validator/QuerySecuritySchema.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
namespace GraphQL\Tests\Validator;
|
||||
|
||||
use GraphQL\Schema;
|
||||
use GraphQL\Type\Definition\ObjectType;
|
||||
use GraphQL\Type\Definition\Type;
|
||||
|
||||
class QuerySecuritySchema
|
||||
{
|
||||
private static $schema;
|
||||
|
||||
private static $dogType;
|
||||
|
||||
private static $humanType;
|
||||
|
||||
private static $queryRootType;
|
||||
|
||||
/**
|
||||
* @return Schema
|
||||
*/
|
||||
public static function buildSchema()
|
||||
{
|
||||
if (null !== self::$schema) {
|
||||
return self::$schema;
|
||||
}
|
||||
|
||||
self::$schema = new Schema(static::buildQueryRootType());
|
||||
|
||||
return self::$schema;
|
||||
}
|
||||
|
||||
public static function buildQueryRootType()
|
||||
{
|
||||
if (null !== self::$queryRootType) {
|
||||
return self::$queryRootType;
|
||||
}
|
||||
|
||||
self::$queryRootType = new ObjectType([
|
||||
'name' => 'QueryRoot',
|
||||
'fields' => [
|
||||
'human' => [
|
||||
'type' => self::buildHumanType(),
|
||||
'args' => ['name' => ['type' => Type::string()]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
return self::$queryRootType;
|
||||
}
|
||||
|
||||
public static function buildHumanType()
|
||||
{
|
||||
if (null !== self::$humanType) {
|
||||
return self::$humanType;
|
||||
}
|
||||
|
||||
self::$humanType = new ObjectType(
|
||||
[
|
||||
'name' => 'Human',
|
||||
'fields' => [
|
||||
'firstName' => ['type' => Type::nonNull(Type::string())],
|
||||
'dogs' => [
|
||||
'type' => function () {
|
||||
return Type::nonNull(
|
||||
Type::listOf(
|
||||
Type::nonNull(self::buildDogType())
|
||||
)
|
||||
);
|
||||
},
|
||||
'complexity' => function ($childrenComplexity, $args) {
|
||||
$complexity = isset($args['name']) ? 1 : 10;
|
||||
|
||||
return $childrenComplexity + $complexity;
|
||||
},
|
||||
'args' => ['name' => ['type' => Type::string()]],
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return self::$humanType;
|
||||
}
|
||||
|
||||
public static function buildDogType()
|
||||
{
|
||||
if (null !== self::$dogType) {
|
||||
return self::$dogType;
|
||||
}
|
||||
|
||||
self::$dogType = new ObjectType(
|
||||
[
|
||||
'name' => 'Dog',
|
||||
'fields' => [
|
||||
'name' => ['type' => Type::nonNull(Type::string())],
|
||||
'master' => [
|
||||
'type' => self::buildHumanType(),
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return self::$dogType;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user