Skip to content

Commit 545fe61

Browse files
author
Jeremiah VALERIE
committed
Add Complexity and Depth Query Security
1 parent 1bc5e0c commit 545fe61

File tree

11 files changed

+1030
-6
lines changed

11 files changed

+1030
-6
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,42 @@ header('Content-Type: application/json');
462462
echo json_encode($result);
463463
```
464464

465+
### Security
466+
467+
#### Query Complexity Analysis
468+
469+
This is a PHP port of [Query Complexity Analysis](http://sangria-graphql.org/learn/#query-complexity-analysis) in Sangria implementation.
470+
Introspection query with description max complexity is **109**.
471+
472+
This document validator rule is disabled by default. Here an example to enabled it:
473+
474+
```php
475+
use GraphQL\GraphQL;
476+
477+
/** @var \GraphQL\Validator\Rules\QueryComplexity $queryComplexity */
478+
$queryComplexity = DocumentValidator::getRule('QueryComplexity');
479+
$queryComplexity->setMaxQueryComplexity($maxQueryComplexity = 110);
480+
481+
GraphQL::execute(/*...*/);
482+
```
483+
484+
#### Limiting Query Depth
485+
486+
This is a PHP port of [Limiting Query Depth](http://sangria-graphql.org/learn/#limiting-query-depth) in Sangria implementation.
487+
Introspection query with description max depth is **7**.
488+
489+
This document validator rule is disabled by default. Here an example to enabled it:
490+
491+
```php
492+
use GraphQL\GraphQL;
493+
494+
/** @var \GraphQL\Validator\Rules\QueryDepth $queryDepth */
495+
$queryDepth = DocumentValidator::getRule('QueryDepth');
496+
$queryDepth->setMaxQueryDepth($maxQueryDepth = 10);
497+
498+
GraphQL::execute(/*...*/);
499+
```
500+
465501
### More Examples
466502
Make sure to check [tests](https://github.com/webonyx/graphql-php/tree/master/tests) for more usage examples.
467503

src/GraphQL.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use GraphQL\Language\Parser;
77
use GraphQL\Language\Source;
88
use GraphQL\Validator\DocumentValidator;
9+
use GraphQL\Validator\Rules\QueryComplexity;
910

1011
class GraphQL
1112
{
@@ -35,6 +36,11 @@ public static function executeAndReturnResult(Schema $schema, $requestString, $r
3536
try {
3637
$source = new Source($requestString ?: '', 'GraphQL request');
3738
$documentAST = Parser::parse($source);
39+
40+
/** @var QueryComplexity $queryComplexity */
41+
$queryComplexity = DocumentValidator::getRule('QueryComplexity');
42+
$queryComplexity->setRawVariableValues($variableValues);
43+
3844
$validationErrors = DocumentValidator::validate($schema, $documentAST);
3945

4046
if (!empty($validationErrors)) {

src/Type/Definition/FieldDefinition.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
class FieldDefinition
77
{
8+
const DEFAULT_COMPLEXITY_FN = 'GraphQL\Type\Definition\FieldDefinition::defaultComplexity';
9+
810
/**
911
* @var string
1012
*/
@@ -72,6 +74,7 @@ public static function getDefinition()
7274
'map' => Config::CALLBACK,
7375
'description' => Config::STRING,
7476
'deprecationReason' => Config::STRING,
77+
'complexity' => Config::CALLBACK,
7578
]);
7679
}
7780

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

115118
$this->config = $config;
119+
120+
$this->complexityFn = isset($config['complexity']) ? $config['complexity'] : static::DEFAULT_COMPLEXITY_FN;
116121
}
117122

118123
/**
@@ -141,4 +146,17 @@ public function getType()
141146
}
142147
return $this->resolvedType;
143148
}
149+
150+
/**
151+
* @return callable|\Closure
152+
*/
153+
public function getComplexityFn()
154+
{
155+
return $this->complexityFn;
156+
}
157+
158+
public static function defaultComplexity($childrenComplexity)
159+
{
160+
return $childrenComplexity + 1;
161+
}
144162
}

src/Validator/DocumentValidator.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
use GraphQL\Validator\Rules\OverlappingFieldsCanBeMerged;
3535
use GraphQL\Validator\Rules\PossibleFragmentSpreads;
3636
use GraphQL\Validator\Rules\ProvidedNonNullArguments;
37+
use GraphQL\Validator\Rules\QueryComplexity;
38+
use GraphQL\Validator\Rules\QueryDepth;
3739
use GraphQL\Validator\Rules\ScalarLeafs;
3840
use GraphQL\Validator\Rules\VariablesAreInputTypes;
3941
use GraphQL\Validator\Rules\VariablesInAllowedPosition;
@@ -82,6 +84,9 @@ public static function defaultRules()
8284
'DefaultValuesOfCorrectType' => new DefaultValuesOfCorrectType(),
8385
'VariablesInAllowedPosition' => new VariablesInAllowedPosition(),
8486
'OverlappingFieldsCanBeMerged' => new OverlappingFieldsCanBeMerged(),
87+
// Query Security
88+
'QueryDepth' => new QueryDepth(QueryDepth::DISABLED), // default disabled
89+
'QueryComplexity' => new QueryComplexity(QueryComplexity::DISABLED), // default disabled
8590
];
8691
}
8792

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

9196
public static function getRule($name)
9297
{
93-
return isset(self::$rules[$name]) ? self::$rules[$name] : null ;
98+
$rules = static::allRules();
99+
100+
return isset($rules[$name]) ? $rules[$name] : null ;
94101
}
95102

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

101-
public static function removeRule($name)
102-
{
103-
unset(self::$rules[$name]);
104-
}
105-
106108
public static function validate(Schema $schema, Document $ast, array $rules = null)
107109
{
108110
$errors = static::visitUsingRules($schema, $ast, $rules ?: static::allRules());
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
namespace GraphQL\Validator\Rules;
4+
5+
use GraphQL\Language\AST\Field;
6+
use GraphQL\Language\AST\FragmentDefinition;
7+
use GraphQL\Language\AST\FragmentSpread;
8+
use GraphQL\Language\AST\InlineFragment;
9+
use GraphQL\Language\AST\Node;
10+
use GraphQL\Language\AST\SelectionSet;
11+
use GraphQL\Type\Definition\Type;
12+
use GraphQL\Type\Introspection;
13+
use GraphQL\Utils\TypeInfo;
14+
use GraphQL\Validator\ValidationContext;
15+
16+
abstract class AbstractQuerySecurity
17+
{
18+
const DISABLED = 0;
19+
20+
/** @var FragmentDefinition[] */
21+
private $fragments = [];
22+
23+
/**
24+
* @return \GraphQL\Language\AST\FragmentDefinition[]
25+
*/
26+
protected function getFragments()
27+
{
28+
return $this->fragments;
29+
}
30+
31+
/**
32+
* check if equal to 0 no check is done. Must be greater or equal to 0.
33+
*
34+
* @param $value
35+
*/
36+
protected function checkIfGreaterOrEqualToZero($name, $value)
37+
{
38+
if ($value < 0) {
39+
throw new \InvalidArgumentException(sprintf('$%s argument must be greater or equal to 0.', $name));
40+
}
41+
}
42+
43+
protected function gatherFragmentDefinition(ValidationContext $context)
44+
{
45+
// Gather all the fragment definition.
46+
// Importantly this does not include inline fragments.
47+
$definitions = $context->getDocument()->definitions;
48+
foreach ($definitions as $node) {
49+
if ($node instanceof FragmentDefinition) {
50+
$this->fragments[$node->name->value] = $node;
51+
}
52+
}
53+
}
54+
55+
protected function getFragment(FragmentSpread $fragmentSpread)
56+
{
57+
$spreadName = $fragmentSpread->name->value;
58+
$fragments = $this->getFragments();
59+
60+
return isset($fragments[$spreadName]) ? $fragments[$spreadName] : null;
61+
}
62+
63+
protected function invokeIfNeeded(ValidationContext $context, array $validators)
64+
{
65+
// is disabled?
66+
if (!$this->isEnabled()) {
67+
return [];
68+
}
69+
70+
$this->gatherFragmentDefinition($context);
71+
72+
return $validators;
73+
}
74+
75+
/**
76+
* Given a selectionSet, adds all of the fields in that selection to
77+
* the passed in map of fields, and returns it at the end.
78+
*
79+
* Note: This is not the same as execution's collectFields because at static
80+
* time we do not know what object type will be used, so we unconditionally
81+
* spread in all fragments.
82+
*
83+
* @see GraphQL\Validator\Rules\OverlappingFieldsCanBeMerged
84+
*
85+
* @param ValidationContext $context
86+
* @param Type|null $parentType
87+
* @param SelectionSet $selectionSet
88+
* @param \ArrayObject $visitedFragmentNames
89+
* @param \ArrayObject $astAndDefs
90+
*
91+
* @return \ArrayObject
92+
*/
93+
protected function collectFieldASTsAndDefs(ValidationContext $context, $parentType, SelectionSet $selectionSet, \ArrayObject $visitedFragmentNames = null, \ArrayObject $astAndDefs = null)
94+
{
95+
$_visitedFragmentNames = $visitedFragmentNames ?: new \ArrayObject();
96+
$_astAndDefs = $astAndDefs ?: new \ArrayObject();
97+
98+
foreach ($selectionSet->selections as $selection) {
99+
switch ($selection->kind) {
100+
case Node::FIELD:
101+
/* @var Field $selection */
102+
$fieldName = $selection->name->value;
103+
$fieldDef = null;
104+
if ($parentType && method_exists($parentType, 'getFields')) {
105+
$tmp = $parentType->getFields();
106+
$schemaMetaFieldDef = Introspection::schemaMetaFieldDef();
107+
$typeMetaFieldDef = Introspection::typeMetaFieldDef();
108+
$typeNameMetaFieldDef = Introspection::typeNameMetaFieldDef();
109+
110+
if ($fieldName === $schemaMetaFieldDef->name && $context->getSchema()->getQueryType() === $parentType) {
111+
$fieldDef = $schemaMetaFieldDef;
112+
} elseif ($fieldName === $typeMetaFieldDef->name && $context->getSchema()->getQueryType() === $parentType) {
113+
$fieldDef = $typeMetaFieldDef;
114+
} elseif ($fieldName === $typeNameMetaFieldDef->name) {
115+
$fieldDef = $typeNameMetaFieldDef;
116+
} elseif (isset($tmp[$fieldName])) {
117+
$fieldDef = $tmp[$fieldName];
118+
}
119+
}
120+
$responseName = $this->getFieldName($selection);
121+
if (!isset($_astAndDefs[$responseName])) {
122+
$_astAndDefs[$responseName] = new \ArrayObject();
123+
}
124+
// create field context
125+
$_astAndDefs[$responseName][] = [$selection, $fieldDef];
126+
break;
127+
case Node::INLINE_FRAGMENT:
128+
/* @var InlineFragment $selection */
129+
$_astAndDefs = $this->collectFieldASTsAndDefs(
130+
$context,
131+
TypeInfo::typeFromAST($context->getSchema(), $selection->typeCondition),
132+
$selection->selectionSet,
133+
$_visitedFragmentNames,
134+
$_astAndDefs
135+
);
136+
break;
137+
case Node::FRAGMENT_SPREAD:
138+
/* @var FragmentSpread $selection */
139+
$fragName = $selection->name->value;
140+
141+
if (empty($_visitedFragmentNames[$fragName])) {
142+
$_visitedFragmentNames[$fragName] = true;
143+
$fragment = $context->getFragment($fragName);
144+
145+
if ($fragment) {
146+
$_astAndDefs = $this->collectFieldASTsAndDefs(
147+
$context,
148+
TypeInfo::typeFromAST($context->getSchema(), $fragment->typeCondition),
149+
$fragment->selectionSet,
150+
$_visitedFragmentNames,
151+
$_astAndDefs
152+
);
153+
}
154+
}
155+
break;
156+
}
157+
}
158+
159+
return $_astAndDefs;
160+
}
161+
162+
protected function getFieldName(Field $node)
163+
{
164+
$fieldName = $node->name->value;
165+
$responseName = $node->alias ? $node->alias->value : $fieldName;
166+
167+
return $responseName;
168+
}
169+
170+
abstract protected function isEnabled();
171+
}

0 commit comments

Comments
 (0)