Skip to content

Commit 36a8454

Browse files
committed
Merge pull request #32 from mcg-web/add_query_security_rules
Add query security document validation rules
2 parents 68d8681 + 545fe61 commit 36a8454

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+1211
-95
lines changed

README.md

+36
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

composer.json

+6-8
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,13 @@
1818
"bin-dir": "bin"
1919
},
2020
"autoload": {
21-
"classmap": [
22-
"src/"
23-
]
21+
"psr-4": {
22+
"GraphQL\\": "src/"
23+
}
2424
},
2525
"autoload-dev": {
26-
"classmap": [
27-
"tests/"
28-
],
29-
"files": [
30-
]
26+
"psr-4": {
27+
"GraphQL\\Tests\\": "tests/"
28+
}
3129
}
3230
}

phpunit.xml.dist

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
3+
<phpunit backupGlobals="false"
4+
backupStaticAttributes="false"
5+
colors="true"
6+
convertErrorsToExceptions="true"
7+
convertNoticesToExceptions="true"
8+
convertWarningsToExceptions="true"
9+
processIsolation="false"
10+
stopOnFailure="false"
11+
syntaxCheck="false"
12+
bootstrap="vendor/autoload.php"
13+
>
14+
<testsuites>
15+
<testsuite name="webonyx/graphql-php Test Suite">
16+
<directory>./tests/</directory>
17+
</testsuite>
18+
</testsuites>
19+
20+
<filter>
21+
<whitelist>
22+
<directory>./</directory>
23+
<exclude>
24+
<directory>./tests</directory>
25+
<directory>./vendor</directory>
26+
</exclude>
27+
</whitelist>
28+
</filter>
29+
30+
<php>
31+
<ini name="error_reporting" value="E_ALL"/>
32+
</php>
33+
34+
</phpunit>

src/GraphQL.php

+6
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

+18
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

+70-38
Original file line numberDiff line numberDiff line change
@@ -34,59 +34,91 @@
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;
4042

4143
class DocumentValidator
4244
{
43-
private static $allRules;
45+
private static $rules = [];
4446

45-
static function allRules()
47+
private static $defaultRules;
48+
49+
private static $initRules = false;
50+
51+
public static function allRules()
52+
{
53+
if (!self::$initRules) {
54+
self::$rules = array_merge(static::defaultRules(), self::$rules);
55+
self::$initRules = true;
56+
}
57+
58+
return self::$rules;
59+
}
60+
61+
public static function defaultRules()
4662
{
47-
if (null === self::$allRules) {
48-
self::$allRules = [
63+
if (null === self::$defaultRules) {
64+
self::$defaultRules = [
4965
// new UniqueOperationNames,
5066
// new LoneAnonymousOperation,
51-
new KnownTypeNames,
52-
new FragmentsOnCompositeTypes,
53-
new VariablesAreInputTypes,
54-
new ScalarLeafs,
55-
new FieldsOnCorrectType,
67+
'KnownTypeNames' => new KnownTypeNames(),
68+
'FragmentsOnCompositeTypes' => new FragmentsOnCompositeTypes(),
69+
'VariablesAreInputTypes' => new VariablesAreInputTypes(),
70+
'ScalarLeafs' => new ScalarLeafs(),
71+
'FieldsOnCorrectType' => new FieldsOnCorrectType(),
5672
// new UniqueFragmentNames,
57-
new KnownFragmentNames,
58-
new NoUnusedFragments,
59-
new PossibleFragmentSpreads,
60-
new NoFragmentCycles,
61-
new NoUndefinedVariables,
62-
new NoUnusedVariables,
63-
new KnownDirectives,
64-
new KnownArgumentNames,
73+
'KnownFragmentNames' => new KnownFragmentNames(),
74+
'NoUnusedFragments' => new NoUnusedFragments(),
75+
'PossibleFragmentSpreads' => new PossibleFragmentSpreads(),
76+
'NoFragmentCycles' => new NoFragmentCycles(),
77+
'NoUndefinedVariables' => new NoUndefinedVariables(),
78+
'NoUnusedVariables' => new NoUnusedVariables(),
79+
'KnownDirectives' => new KnownDirectives(),
80+
'KnownArgumentNames' => new KnownArgumentNames(),
6581
// new UniqueArgumentNames,
66-
new ArgumentsOfCorrectType,
67-
new ProvidedNonNullArguments,
68-
new DefaultValuesOfCorrectType,
69-
new VariablesInAllowedPosition,
70-
new OverlappingFieldsCanBeMerged,
82+
'ArgumentsOfCorrectType' => new ArgumentsOfCorrectType(),
83+
'ProvidedNonNullArguments' => new ProvidedNonNullArguments(),
84+
'DefaultValuesOfCorrectType' => new DefaultValuesOfCorrectType(),
85+
'VariablesInAllowedPosition' => new VariablesInAllowedPosition(),
86+
'OverlappingFieldsCanBeMerged' => new OverlappingFieldsCanBeMerged(),
87+
// Query Security
88+
'QueryDepth' => new QueryDepth(QueryDepth::DISABLED), // default disabled
89+
'QueryComplexity' => new QueryComplexity(QueryComplexity::DISABLED), // default disabled
7190
];
7291
}
73-
return self::$allRules;
92+
93+
return self::$defaultRules;
94+
}
95+
96+
public static function getRule($name)
97+
{
98+
$rules = static::allRules();
99+
100+
return isset($rules[$name]) ? $rules[$name] : null ;
101+
}
102+
103+
public static function addRule($name, callable $rule)
104+
{
105+
self::$rules[$name] = $rule;
74106
}
75107

76108
public static function validate(Schema $schema, Document $ast, array $rules = null)
77109
{
78-
$errors = self::visitUsingRules($schema, $ast, $rules ?: self::allRules());
110+
$errors = static::visitUsingRules($schema, $ast, $rules ?: static::allRules());
79111
return $errors;
80112
}
81113

82-
static function isError($value)
114+
public static function isError($value)
83115
{
84116
return is_array($value)
85117
? count(array_filter($value, function($item) { return $item instanceof \Exception;})) === count($value)
86118
: $value instanceof \Exception;
87119
}
88120

89-
static function append(&$arr, $items)
121+
public static function append(&$arr, $items)
90122
{
91123
if (is_array($items)) {
92124
$arr = array_merge($arr, $items);
@@ -96,7 +128,7 @@ static function append(&$arr, $items)
96128
return $arr;
97129
}
98130

99-
static function isValidLiteralValue($valueAST, Type $type)
131+
public static function isValidLiteralValue($valueAST, Type $type)
100132
{
101133
// A value can only be not provided if the type is nullable.
102134
if (!$valueAST) {
@@ -105,7 +137,7 @@ static function isValidLiteralValue($valueAST, Type $type)
105137

106138
// Unwrap non-null.
107139
if ($type instanceof NonNull) {
108-
return self::isValidLiteralValue($valueAST, $type->getWrappedType());
140+
return static::isValidLiteralValue($valueAST, $type->getWrappedType());
109141
}
110142

111143
// This function only tests literals, and assumes variables will provide
@@ -123,13 +155,13 @@ static function isValidLiteralValue($valueAST, Type $type)
123155
$itemType = $type->getWrappedType();
124156
if ($valueAST instanceof ListValue) {
125157
foreach($valueAST->values as $itemAST) {
126-
if (!self::isValidLiteralValue($itemAST, $itemType)) {
158+
if (!static::isValidLiteralValue($itemAST, $itemType)) {
127159
return false;
128160
}
129161
}
130162
return true;
131163
} else {
132-
return self::isValidLiteralValue($valueAST, $itemType);
164+
return static::isValidLiteralValue($valueAST, $itemType);
133165
}
134166
}
135167

@@ -157,7 +189,7 @@ static function isValidLiteralValue($valueAST, Type $type)
157189
}
158190
}
159191
foreach ($fieldASTs as $fieldAST) {
160-
if (empty($fields[$fieldAST->name->value]) || !self::isValidLiteralValue($fieldAST->value, $fields[$fieldAST->name->value]->getType())) {
192+
if (empty($fields[$fieldAST->name->value]) || !static::isValidLiteralValue($fieldAST->value, $fields[$fieldAST->name->value]->getType())) {
161193
return false;
162194
}
163195
}
@@ -231,8 +263,8 @@ public static function visitUsingRules(Schema $schema, Document $documentAST, ar
231263
} else if ($result->doBreak) {
232264
$instances[$i] = null;
233265
}
234-
} else if ($result && self::isError($result)) {
235-
self::append($errors, $result);
266+
} else if ($result && static::isError($result)) {
267+
static::append($errors, $result);
236268
for ($j = $i - 1; $j >= 0; $j--) {
237269
$leaveFn = Visitor::getVisitFn($instances[$j], true, $node->kind);
238270
if ($leaveFn) {
@@ -243,8 +275,8 @@ public static function visitUsingRules(Schema $schema, Document $documentAST, ar
243275
if ($result->doBreak) {
244276
$instances[$j] = null;
245277
}
246-
} else if (self::isError($result)) {
247-
self::append($errors, $result);
278+
} else if (static::isError($result)) {
279+
static::append($errors, $result);
248280
} else if ($result !== null) {
249281
throw new \Exception("Config cannot edit document.");
250282
}
@@ -294,8 +326,8 @@ public static function visitUsingRules(Schema $schema, Document $documentAST, ar
294326
if ($result->doBreak) {
295327
$instances[$i] = null;
296328
}
297-
} else if (self::isError($result)) {
298-
self::append($errors, $result);
329+
} else if (static::isError($result)) {
330+
static::append($errors, $result);
299331
} else if ($result !== null) {
300332
throw new \Exception("Config cannot edit document.");
301333
}
@@ -309,7 +341,7 @@ public static function visitUsingRules(Schema $schema, Document $documentAST, ar
309341
// Visit the whole document with instances of all provided rules.
310342
$allRuleInstances = [];
311343
foreach ($rules as $rule) {
312-
$allRuleInstances[] = $rule($context);
344+
$allRuleInstances[] = call_user_func_array($rule, [$context]);
313345
}
314346
$visitInstances($documentAST, $allRuleInstances);
315347

0 commit comments

Comments
 (0)