From 545fe616a05158265a895de3cec1028abefa9a9e Mon Sep 17 00:00:00 2001 From: Jeremiah VALERIE Date: Sat, 9 Apr 2016 10:04:14 +0200 Subject: [PATCH] Add Complexity and Depth Query Security --- README.md | 36 +++ src/GraphQL.php | 6 + src/Type/Definition/FieldDefinition.php | 18 ++ src/Validator/DocumentValidator.php | 14 +- src/Validator/Rules/AbstractQuerySecurity.php | 171 ++++++++++++++ src/Validator/Rules/QueryComplexity.php | 212 ++++++++++++++++++ src/Validator/Rules/QueryDepth.php | 116 ++++++++++ tests/Validator/AbstractQuerySecurityTest.php | 95 ++++++++ tests/Validator/QueryComplexityTest.php | 116 ++++++++++ tests/Validator/QueryDepthTest.php | 148 ++++++++++++ tests/Validator/QuerySecuritySchema.php | 104 +++++++++ 11 files changed, 1030 insertions(+), 6 deletions(-) create mode 100644 src/Validator/Rules/AbstractQuerySecurity.php create mode 100644 src/Validator/Rules/QueryComplexity.php create mode 100644 src/Validator/Rules/QueryDepth.php create mode 100644 tests/Validator/AbstractQuerySecurityTest.php create mode 100644 tests/Validator/QueryComplexityTest.php create mode 100644 tests/Validator/QueryDepthTest.php create mode 100644 tests/Validator/QuerySecuritySchema.php diff --git a/README.md b/README.md index ac59668..30c2044 100644 --- a/README.md +++ b/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. diff --git a/src/GraphQL.php b/src/GraphQL.php index 22b91b9..6ad0ec8 100644 --- a/src/GraphQL.php +++ b/src/GraphQL.php @@ -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)) { diff --git a/src/Type/Definition/FieldDefinition.php b/src/Type/Definition/FieldDefinition.php index 23edb7a..c17db33 100644 --- a/src/Type/Definition/FieldDefinition.php +++ b/src/Type/Definition/FieldDefinition.php @@ -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; + } } diff --git a/src/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php index 3316cf0..19c7c50 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -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()); diff --git a/src/Validator/Rules/AbstractQuerySecurity.php b/src/Validator/Rules/AbstractQuerySecurity.php new file mode 100644 index 0000000..a35c046 --- /dev/null +++ b/src/Validator/Rules/AbstractQuerySecurity.php @@ -0,0 +1,171 @@ +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(); +} diff --git a/src/Validator/Rules/QueryComplexity.php b/src/Validator/Rules/QueryComplexity.php new file mode 100644 index 0000000..8ce4afc --- /dev/null +++ b/src/Validator/Rules/QueryComplexity.php @@ -0,0 +1,212 @@ +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; + } +} diff --git a/src/Validator/Rules/QueryDepth.php b/src/Validator/Rules/QueryDepth.php new file mode 100644 index 0000000..694262a --- /dev/null +++ b/src/Validator/Rules/QueryDepth.php @@ -0,0 +1,116 @@ +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; + } +} diff --git a/tests/Validator/AbstractQuerySecurityTest.php b/tests/Validator/AbstractQuerySecurityTest.php new file mode 100644 index 0000000..6870441 --- /dev/null +++ b/tests/Validator/AbstractQuerySecurityTest.php @@ -0,0 +1,95 @@ +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)]); + } + } +} diff --git a/tests/Validator/QueryComplexityTest.php b/tests/Validator/QueryComplexityTest.php new file mode 100644 index 0000000..fecde04 --- /dev/null +++ b/tests/Validator/QueryComplexityTest.php @@ -0,0 +1,116 @@ +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); + } + } +} diff --git a/tests/Validator/QueryDepthTest.php b/tests/Validator/QueryDepthTest.php new file mode 100644 index 0000000..a4f7837 --- /dev/null +++ b/tests/Validator/QueryDepthTest.php @@ -0,0 +1,148 @@ +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; + } +} diff --git a/tests/Validator/QuerySecuritySchema.php b/tests/Validator/QuerySecuritySchema.php new file mode 100644 index 0000000..fc94224 --- /dev/null +++ b/tests/Validator/QuerySecuritySchema.php @@ -0,0 +1,104 @@ + '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; + } +}