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/composer.json b/composer.json index 0ac139f..dabf1cd 100644 --- a/composer.json +++ b/composer.json @@ -18,15 +18,13 @@ "bin-dir": "bin" }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "GraphQL\\": "src/" + } }, "autoload-dev": { - "classmap": [ - "tests/" - ], - "files": [ - ] + "psr-4": { + "GraphQL\\Tests\\": "tests/" + } } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..3b3b949 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,34 @@ + + + + + + ./tests/ + + + + + + ./ + + ./tests + ./vendor + + + + + + + + + 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 05157a1..19c7c50 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -34,59 +34,91 @@ 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; class DocumentValidator { - private static $allRules; + private static $rules = []; - static function allRules() + private static $defaultRules; + + private static $initRules = false; + + public static function allRules() { - if (null === self::$allRules) { - self::$allRules = [ + if (!self::$initRules) { + self::$rules = array_merge(static::defaultRules(), self::$rules); + self::$initRules = true; + } + + return self::$rules; + } + + public static function defaultRules() + { + if (null === self::$defaultRules) { + self::$defaultRules = [ // new UniqueOperationNames, // new LoneAnonymousOperation, - new KnownTypeNames, - new FragmentsOnCompositeTypes, - new VariablesAreInputTypes, - new ScalarLeafs, - new FieldsOnCorrectType, + 'KnownTypeNames' => new KnownTypeNames(), + 'FragmentsOnCompositeTypes' => new FragmentsOnCompositeTypes(), + 'VariablesAreInputTypes' => new VariablesAreInputTypes(), + 'ScalarLeafs' => new ScalarLeafs(), + 'FieldsOnCorrectType' => new FieldsOnCorrectType(), // new UniqueFragmentNames, - new KnownFragmentNames, - new NoUnusedFragments, - new PossibleFragmentSpreads, - new NoFragmentCycles, - new NoUndefinedVariables, - new NoUnusedVariables, - new KnownDirectives, - new KnownArgumentNames, + 'KnownFragmentNames' => new KnownFragmentNames(), + 'NoUnusedFragments' => new NoUnusedFragments(), + 'PossibleFragmentSpreads' => new PossibleFragmentSpreads(), + 'NoFragmentCycles' => new NoFragmentCycles(), + 'NoUndefinedVariables' => new NoUndefinedVariables(), + 'NoUnusedVariables' => new NoUnusedVariables(), + 'KnownDirectives' => new KnownDirectives(), + 'KnownArgumentNames' => new KnownArgumentNames(), // new UniqueArgumentNames, - new ArgumentsOfCorrectType, - new ProvidedNonNullArguments, - new DefaultValuesOfCorrectType, - new VariablesInAllowedPosition, - new OverlappingFieldsCanBeMerged, + 'ArgumentsOfCorrectType' => new ArgumentsOfCorrectType(), + 'ProvidedNonNullArguments' => new ProvidedNonNullArguments(), + '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 ]; } - return self::$allRules; + + return self::$defaultRules; + } + + public static function getRule($name) + { + $rules = static::allRules(); + + return isset($rules[$name]) ? $rules[$name] : null ; + } + + public static function addRule($name, callable $rule) + { + self::$rules[$name] = $rule; } public static function validate(Schema $schema, Document $ast, array $rules = null) { - $errors = self::visitUsingRules($schema, $ast, $rules ?: self::allRules()); + $errors = static::visitUsingRules($schema, $ast, $rules ?: static::allRules()); return $errors; } - static function isError($value) + public static function isError($value) { return is_array($value) ? count(array_filter($value, function($item) { return $item instanceof \Exception;})) === count($value) : $value instanceof \Exception; } - static function append(&$arr, $items) + public static function append(&$arr, $items) { if (is_array($items)) { $arr = array_merge($arr, $items); @@ -96,7 +128,7 @@ class DocumentValidator return $arr; } - static function isValidLiteralValue($valueAST, Type $type) + public static function isValidLiteralValue($valueAST, Type $type) { // A value can only be not provided if the type is nullable. if (!$valueAST) { @@ -105,7 +137,7 @@ class DocumentValidator // Unwrap non-null. if ($type instanceof NonNull) { - return self::isValidLiteralValue($valueAST, $type->getWrappedType()); + return static::isValidLiteralValue($valueAST, $type->getWrappedType()); } // This function only tests literals, and assumes variables will provide @@ -123,13 +155,13 @@ class DocumentValidator $itemType = $type->getWrappedType(); if ($valueAST instanceof ListValue) { foreach($valueAST->values as $itemAST) { - if (!self::isValidLiteralValue($itemAST, $itemType)) { + if (!static::isValidLiteralValue($itemAST, $itemType)) { return false; } } return true; } else { - return self::isValidLiteralValue($valueAST, $itemType); + return static::isValidLiteralValue($valueAST, $itemType); } } @@ -157,7 +189,7 @@ class DocumentValidator } } foreach ($fieldASTs as $fieldAST) { - if (empty($fields[$fieldAST->name->value]) || !self::isValidLiteralValue($fieldAST->value, $fields[$fieldAST->name->value]->getType())) { + if (empty($fields[$fieldAST->name->value]) || !static::isValidLiteralValue($fieldAST->value, $fields[$fieldAST->name->value]->getType())) { return false; } } @@ -231,8 +263,8 @@ class DocumentValidator } else if ($result->doBreak) { $instances[$i] = null; } - } else if ($result && self::isError($result)) { - self::append($errors, $result); + } else if ($result && static::isError($result)) { + static::append($errors, $result); for ($j = $i - 1; $j >= 0; $j--) { $leaveFn = Visitor::getVisitFn($instances[$j], true, $node->kind); if ($leaveFn) { @@ -243,8 +275,8 @@ class DocumentValidator if ($result->doBreak) { $instances[$j] = null; } - } else if (self::isError($result)) { - self::append($errors, $result); + } else if (static::isError($result)) { + static::append($errors, $result); } else if ($result !== null) { throw new \Exception("Config cannot edit document."); } @@ -294,8 +326,8 @@ class DocumentValidator if ($result->doBreak) { $instances[$i] = null; } - } else if (self::isError($result)) { - self::append($errors, $result); + } else if (static::isError($result)) { + static::append($errors, $result); } else if ($result !== null) { throw new \Exception("Config cannot edit document."); } @@ -309,7 +341,7 @@ class DocumentValidator // Visit the whole document with instances of all provided rules. $allRuleInstances = []; foreach ($rules as $rule) { - $allRuleInstances[] = $rule($context); + $allRuleInstances[] = call_user_func_array($rule, [$context]); } $visitInstances($documentAST, $allRuleInstances); 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/Executor/AbstractTest.php b/tests/Executor/AbstractTest.php index 378810f..3540256 100644 --- a/tests/Executor/AbstractTest.php +++ b/tests/Executor/AbstractTest.php @@ -1,8 +1,8 @@ assertEquals($expectedDefaultSelection, $actualDefaultSelection); $this->assertEquals($expectedDeepSelection, $actualDeepSelection); } -} \ No newline at end of file +} diff --git a/tests/Type/ScalarSerializationTest.php b/tests/Type/ScalarSerializationTest.php index 80ef84b..83af55d 100644 --- a/tests/Type/ScalarSerializationTest.php +++ b/tests/Type/ScalarSerializationTest.php @@ -1,5 +1,5 @@ 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/ArgumentsOfCorrectTypeTest.php b/tests/Validator/ArgumentsOfCorrectTypeTest.php index 74e5181..577be7c 100644 --- a/tests/Validator/ArgumentsOfCorrectTypeTest.php +++ b/tests/Validator/ArgumentsOfCorrectTypeTest.php @@ -1,5 +1,5 @@ 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; + } +} diff --git a/tests/Validator/ScalarLeafsTest.php b/tests/Validator/ScalarLeafsTest.php index 1fe67dd..0b175a2 100644 --- a/tests/Validator/ScalarLeafsTest.php +++ b/tests/Validator/ScalarLeafsTest.php @@ -1,5 +1,5 @@