diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 8d10bf710..8f5365c52 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,6 +1,6 @@ tools: external_code_coverage: - timeout: 600 + timeout: 1200 build: tests: override: diff --git a/.travis.yml b/.travis.yml index 2cee515b9..98ad9656a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,7 +35,7 @@ before_install: install: composer update --prefer-dist --no-interaction -script: if [ "$TRAVIS_PHP_VERSION" == "5.6" ]; then php -d xdebug.max_nesting_level=1000 vendor/bin/phpunit --coverage-clover=coverage.clover; else vendor/bin/phpunit; fi +script: if [ "$TRAVIS_PHP_VERSION" == "5.6" ]; then php -d xdebug.max_nesting_level=1000 vendor/bin/phpunit --debug --coverage-clover=coverage.clover; else vendor/bin/phpunit --debug; fi after_script: - if [ "$TRAVIS_PHP_VERSION" == "5.6" ]; then wget https://scrutinizer-ci.com/ocular.phar && php ocular.phar code-coverage:upload --format=php-clover coverage.clover; fi diff --git a/Definition/Builder/TypeBuilder.php b/Definition/Builder/TypeBuilder.php index e88018ea6..c66b65e78 100644 --- a/Definition/Builder/TypeBuilder.php +++ b/Definition/Builder/TypeBuilder.php @@ -11,7 +11,7 @@ namespace Overblog\GraphQLBundle\Definition\Builder; -use GraphQL\Type\Definition\Type; +use Overblog\GraphQLBundle\Definition\Type; use Overblog\GraphQLBundle\Resolver\ResolverInterface; class TypeBuilder @@ -23,11 +23,11 @@ class TypeBuilder 'relay-node' => 'Overblog\\GraphQLBundle\\Relay\\Node\\NodeInterfaceType', 'relay-mutation-input' => 'Overblog\\GraphQLBundle\\Relay\\Mutation\\InputType', 'relay-mutation-payload' => 'Overblog\\GraphQLBundle\\Relay\\Mutation\\PayloadType', - 'object' => 'GraphQL\\Type\\Definition\\ObjectType', - 'enum' => 'GraphQL\\Type\\Definition\\EnumType', - 'interface' => 'GraphQL\\Type\\Definition\\InterfaceType', - 'union' => 'GraphQL\\Type\\Definition\\UnionType', - 'input-object' => 'GraphQL\\Type\\Definition\\InputObjectType', + 'object' => 'Overblog\\GraphQLBundle\\Definition\\ObjectType', + 'enum' => 'Overblog\\GraphQLBundle\\Definition\\EnumType', + 'interface' => 'Overblog\\GraphQLBundle\\Definition\\InterfaceType', + 'union' => 'Overblog\\GraphQLBundle\\Definition\\UnionType', + 'input-object' => 'Overblog\\GraphQLBundle\\Definition\\InputObjectType', ]; public function __construct(ResolverInterface $configResolver) diff --git a/Definition/Builder/ConfigBuilderInterface.php b/Definition/EnumType.php similarity index 58% rename from Definition/Builder/ConfigBuilderInterface.php rename to Definition/EnumType.php index 9fbf6a5fc..6de97e4d0 100644 --- a/Definition/Builder/ConfigBuilderInterface.php +++ b/Definition/EnumType.php @@ -9,11 +9,10 @@ * file that was distributed with this source code. */ -namespace Overblog\GraphQLBundle\Definition\Builder; +namespace Overblog\GraphQLBundle\Definition; -interface ConfigBuilderInterface -{ - public function create($type, array $config); +use GraphQL\Type\Definition\EnumType as BaseEnumType; - public function getBaseClassName($type); +class EnumType extends BaseEnumType +{ } diff --git a/Definition/FieldDefinition.php b/Definition/FieldDefinition.php new file mode 100644 index 000000000..3674afbee --- /dev/null +++ b/Definition/FieldDefinition.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Definition; + +use GraphQL\Type\Definition\Config; +use GraphQL\Type\Definition\FieldDefinition as BaseFieldDefinition; + +class FieldDefinition extends BaseFieldDefinition +{ + const DEFAULT_COMPLEXITY_FN = '\Overblog\GraphQLBundle\Definition\FieldDefinition::defaultComplexity'; + + /** + * @var callable + */ + private $complexityFn; + + public static function getDefinition() + { + return array_merge( + parent::getDefinition(), + [ + 'complexity' => Config::CALLBACK, + ] + ); + } + + public static function createMap(array $fields) + { + $map = []; + foreach ($fields as $name => $field) { + if (!isset($field['name'])) { + $field['name'] = $name; + } + $map[$name] = static::create($field); + } + + return $map; + } + + /** + * @param array $field + * + * @return FieldDefinition + */ + public static function create($field) + { + Config::validate($field, static::getDefinition()); + + return new static($field); + } + + protected function __construct(array $config) + { + parent::__construct($config); + + $this->complexityFn = isset($config['complexity']) ? $config['complexity'] : static::DEFAULT_COMPLEXITY_FN; + } + + /** + * @return callable|\Closure + */ + public function getComplexityFn() + { + return $this->complexityFn; + } + + public static function defaultComplexity($childrenComplexity) + { + return $childrenComplexity + 1; + } +} diff --git a/Definition/InputObjectType.php b/Definition/InputObjectType.php new file mode 100644 index 000000000..122802fed --- /dev/null +++ b/Definition/InputObjectType.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Definition; + +use GraphQL\Type\Definition\InputObjectType as BaseInputObjectType; + +class InputObjectType extends BaseInputObjectType +{ +} diff --git a/Definition/InterfaceType.php b/Definition/InterfaceType.php new file mode 100644 index 000000000..eb5047563 --- /dev/null +++ b/Definition/InterfaceType.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Definition; + +use GraphQL\Type\Definition\InterfaceType as BaseInterfaceType; + +class InterfaceType extends BaseInterfaceType +{ +} diff --git a/Definition/ObjectType.php b/Definition/ObjectType.php new file mode 100644 index 000000000..2cae4022a --- /dev/null +++ b/Definition/ObjectType.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Definition; + +use GraphQL\Type\Definition\Config; +use GraphQL\Type\Definition\ObjectType as BaseObjectType; +use GraphQL\Type\Definition\ResolveInfo; +use GraphQL\Utils; + +class ObjectType extends BaseObjectType +{ + private $isTypeOf; + + /** + * @var FieldDefinition[] + */ + private $fields; + + /** + * @param array $config + * + * @todo open PR on lib to ease inheritance + */ + public function __construct(array $config) + { + Utils::invariant(!empty($config['name']), 'Every type is expected to have name'); + + Config::validate($config, [ + 'name' => Config::STRING | Config::REQUIRED, + 'fields' => Config::arrayOf( + FieldDefinition::getDefinition(), + Config::KEY_AS_NAME | Config::MAYBE_THUNK + ), + 'description' => Config::STRING, + 'interfaces' => Config::arrayOf( + Config::INTERFACE_TYPE, + Config::MAYBE_THUNK + ), + 'isTypeOf' => Config::CALLBACK, // ($value, ResolveInfo $info) => boolean + 'resolveField' => Config::CALLBACK, + ]); + + $this->name = $config['name']; + $this->description = isset($config['description']) ? $config['description'] : null; + $this->resolveFieldFn = isset($config['resolveField']) ? $config['resolveField'] : null; + $this->isTypeOf = isset($config['isTypeOf']) ? $config['isTypeOf'] : null; + $this->config = $config; + + if (isset($config['interfaces'])) { + InterfaceType::addImplementationToInterfaces($this); + } + } + + public function getFields() + { + if (null === $this->fields) { + $fields = isset($this->config['fields']) ? $this->config['fields'] : []; + $fields = is_callable($fields) ? call_user_func($fields) : $fields; + $this->fields = FieldDefinition::createMap($fields); + } + + return $this->fields; + } + + public function getField($name) + { + if (null === $this->fields) { + $this->getFields(); + } + Utils::invariant(isset($this->fields[$name]), "Field '%s' is not defined for type '%s'", $name, $this->name); + + return $this->fields[$name]; + } + + public function isTypeOf($value, ResolveInfo $info) + { + return isset($this->isTypeOf) ? call_user_func($this->isTypeOf, $value, $info) : null; + } +} diff --git a/Definition/Type.php b/Definition/Type.php new file mode 100644 index 000000000..260d851e4 --- /dev/null +++ b/Definition/Type.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Definition; + +use GraphQL\Type\Definition\Type as BaseType; + +class Type extends BaseType +{ +} diff --git a/Definition/UnionType.php b/Definition/UnionType.php new file mode 100644 index 000000000..fcf3aa9d0 --- /dev/null +++ b/Definition/UnionType.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Definition; + +use GraphQL\Type\Definition\UnionType as BaseUnionType; + +class UnionType extends BaseUnionType +{ +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index a53dd65fa..33426dc0d 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -11,6 +11,7 @@ namespace Overblog\GraphQLBundle\DependencyInjection; +use Overblog\GraphQLBundle\Request\Validator\Rule\QueryDepth; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -92,18 +93,8 @@ public function getConfigTreeBuilder() ->arrayNode('security') ->addDefaultsIfNotSet() ->children() - ->integerNode('query_max_depth') - ->info('Limit query depth. Disabled if equal to false or 0.') - ->beforeNormalization() - ->ifTrue(function ($v) { return false === $v; }) - ->then(function () { return 0; }) - ->end() - ->defaultFalse() - ->validate() - ->ifTrue(function ($v) { return $v < 0; }) - ->thenInvalid('"overblog_graphql.security.query_max_depth" must be greater or equal to 0.') - ->end() - ->end() + ->append($this->addSecurityQuerySection('query_max_depth', QueryDepth::DISABLED)) + ->append($this->addSecurityQuerySection('query_max_complexity', QueryDepth::DISABLED)) ->end() ->end() ->end() @@ -111,4 +102,25 @@ public function getConfigTreeBuilder() return $treeBuilder; } + + private function addSecurityQuerySection($name, $disabledValue) + { + $builder = new TreeBuilder(); + $node = $builder->root($name, 'integer'); + + $node + ->info('Disabled if equal to false.') + ->beforeNormalization() + ->ifTrue(function ($v) { return false === $v; }) + ->then(function () use ($disabledValue) { return $disabledValue; }) + ->end() + ->defaultFalse() + ->validate() + ->ifTrue(function ($v) { return $v < 0; }) + ->thenInvalid('"overblog_graphql.security.'.$name.'" must be greater or equal to 0.') + ->end() + ; + + return $node; + } } diff --git a/DependencyInjection/OverblogGraphQLExtension.php b/DependencyInjection/OverblogGraphQLExtension.php index 39dc983d3..d46cc7403 100644 --- a/DependencyInjection/OverblogGraphQLExtension.php +++ b/DependencyInjection/OverblogGraphQLExtension.php @@ -54,11 +54,19 @@ private function setSecurity(array $config, ContainerBuilder $container) { if (isset($config['security']['query_max_depth'])) { $container - ->getDefinition($this->getAlias().'.request_validator_rule_max_query_depth') + ->getDefinition($this->getAlias().'.request_validator_rule_query_depth') ->addMethodCall('setMaxQueryDepth', [$config['security']['query_max_depth']]) ->setPublic(true) ; } + + if (isset($config['security']['query_max_complexity'])) { + $container + ->getDefinition($this->getAlias().'.request_validator_rule_query_complexity') + ->addMethodCall('setMaxQueryComplexity', [$config['security']['query_max_complexity']]) + ->setPublic(true) + ; + } } private function setGraphiQLTemplate(array $config, ContainerBuilder $container) diff --git a/DependencyInjection/TypesConfiguration.php b/DependencyInjection/TypesConfiguration.php index 678539d83..1d83c68fb 100644 --- a/DependencyInjection/TypesConfiguration.php +++ b/DependencyInjection/TypesConfiguration.php @@ -186,6 +186,9 @@ private function addFieldsSelection($name, $enabledBuilder = true) } $prototype + ->scalarNode('complexity') + ->info('Custom complexity calculator.') + ->end() ->end() ->end(); diff --git a/README.md b/README.md index 36d22f471..b46d77c13 100644 --- a/README.md +++ b/README.md @@ -613,9 +613,11 @@ class CharacterResolver } ``` +Security +-------- + +### Access Control -Access Control --------------- An access control can be add on each field using `config.fields.*.access` or globally with `config.fieldsDefaultAccess`. If `config.fields.*.access` value is true field will be normally resolved but will be `null` otherwise. @@ -649,6 +651,58 @@ Human: interfaces: [Character] ``` +### 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**. + +Define your max accepted complexity: + +```yaml +#app/config/config.yml +overblog_graphql: + security: + query_max_complexity: 1000 +``` + +Default value `false` disabled validation. + +Customize your field complexity using `config.fields.*.complexity` + +```yaml +# src/MyBundle/Resources/config/graphql/Query.types.yml + +Query: + type: object + config: + fields: + droid: + type: "Droid" + complexity: '@=1000 + childrenComplexity' + args: + id: + description: "id of the droid" + type: "String!" + resolve: "@=resolver('character_droid', [args])" +``` + +In the example we add `1000` on the complexity every time using `Query.droid` field in query. +Complexity function signature: `function (int $childrenComplexity = 0, array $args = [])`. + +### 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**. + +```yaml +#app/config/config.yml +overblog_graphql: + security: + query_max_depth: 10 +``` + +Default value `false` disabled validation. + Field builder ------------- @@ -711,6 +765,7 @@ Expression | Description | Scope **value** | Resolver value | only available in resolve context **args** | Resolver args array | only available in resolve context **info** | Resolver GraphQL\Type\Definition\ResolveInfo Object | only available in resolve context +**childrenComplexity** | Selection field children complexity | only available in complexity context [For more details on expression syntax](http://symfony.com/doc/current/components/expression_language/syntax.html) diff --git a/Relay/Connection/ConnectionType.php b/Relay/Connection/ConnectionType.php index bffff0302..3bd3789b5 100644 --- a/Relay/Connection/ConnectionType.php +++ b/Relay/Connection/ConnectionType.php @@ -13,10 +13,10 @@ use GraphQL\Type\Definition\Config; use GraphQL\Type\Definition\FieldDefinition; -use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Utils; use Overblog\GraphQLBundle\Definition\MergeFieldTrait; +use Overblog\GraphQLBundle\Definition\ObjectType; class ConnectionType extends ObjectType { diff --git a/Relay/Connection/EdgeType.php b/Relay/Connection/EdgeType.php index 864407478..7a3c3bb82 100644 --- a/Relay/Connection/EdgeType.php +++ b/Relay/Connection/EdgeType.php @@ -11,7 +11,7 @@ namespace Overblog\GraphQLBundle\Relay\Connection; -use GraphQL\Type\Definition\ObjectType; +use Overblog\GraphQLBundle\Definition\ObjectType; class EdgeType extends ObjectType { diff --git a/Relay/Connection/PageInfoType.php b/Relay/Connection/PageInfoType.php index b9f10863b..a8306e686 100644 --- a/Relay/Connection/PageInfoType.php +++ b/Relay/Connection/PageInfoType.php @@ -11,8 +11,8 @@ namespace Overblog\GraphQLBundle\Relay\Connection; -use GraphQL\Type\Definition\ObjectType; -use GraphQL\Type\Definition\Type; +use Overblog\GraphQLBundle\Definition\ObjectType; +use Overblog\GraphQLBundle\Definition\Type; class PageInfoType extends ObjectType { diff --git a/Relay/Mutation/InputType.php b/Relay/Mutation/InputType.php index 0761cf6ca..14c9ee845 100644 --- a/Relay/Mutation/InputType.php +++ b/Relay/Mutation/InputType.php @@ -12,11 +12,11 @@ namespace Overblog\GraphQLBundle\Relay\Mutation; use GraphQL\Type\Definition\Config; -use GraphQL\Type\Definition\FieldDefinition; -use GraphQL\Type\Definition\InputObjectType; -use GraphQL\Type\Definition\Type; use GraphQL\Utils; +use Overblog\GraphQLBundle\Definition\FieldDefinition; +use Overblog\GraphQLBundle\Definition\InputObjectType; use Overblog\GraphQLBundle\Definition\MergeFieldTrait; +use Overblog\GraphQLBundle\Definition\Type; class InputType extends InputObjectType { diff --git a/Relay/Mutation/MutationField.php b/Relay/Mutation/MutationField.php index 4ac237777..2544331aa 100644 --- a/Relay/Mutation/MutationField.php +++ b/Relay/Mutation/MutationField.php @@ -12,11 +12,11 @@ namespace Overblog\GraphQLBundle\Relay\Mutation; use GraphQL\Type\Definition\Config; -use GraphQL\Type\Definition\Type; use GraphQL\Utils; use Overblog\GraphQLBundle\Definition\Argument; use Overblog\GraphQLBundle\Definition\Builder\MappingInterface; use Overblog\GraphQLBundle\Definition\MergeFieldTrait; +use Overblog\GraphQLBundle\Definition\Type; use Overblog\GraphQLBundle\Resolver\Resolver; class MutationField implements MappingInterface diff --git a/Relay/Mutation/PayloadType.php b/Relay/Mutation/PayloadType.php index a5423e9a0..1fce68290 100644 --- a/Relay/Mutation/PayloadType.php +++ b/Relay/Mutation/PayloadType.php @@ -12,11 +12,11 @@ namespace Overblog\GraphQLBundle\Relay\Mutation; use GraphQL\Type\Definition\Config; -use GraphQL\Type\Definition\FieldDefinition; -use GraphQL\Type\Definition\ObjectType; -use GraphQL\Type\Definition\Type; use GraphQL\Utils; +use Overblog\GraphQLBundle\Definition\FieldDefinition; use Overblog\GraphQLBundle\Definition\MergeFieldTrait; +use Overblog\GraphQLBundle\Definition\ObjectType; +use Overblog\GraphQLBundle\Definition\Type; class PayloadType extends ObjectType { diff --git a/Relay/Node/GlobalIdField.php b/Relay/Node/GlobalIdField.php index ffa6a48d6..6bde8735e 100644 --- a/Relay/Node/GlobalIdField.php +++ b/Relay/Node/GlobalIdField.php @@ -13,8 +13,8 @@ use GraphQL\Type\Definition\Config; use GraphQL\Type\Definition\ResolveInfo; -use GraphQL\Type\Definition\Type; use Overblog\GraphQLBundle\Definition\Builder\MappingInterface; +use Overblog\GraphQLBundle\Definition\Type; use Overblog\GraphQLBundle\Resolver\Resolver; class GlobalIdField implements MappingInterface diff --git a/Relay/Node/NodeField.php b/Relay/Node/NodeField.php index 2f595ece4..80d5efe3c 100644 --- a/Relay/Node/NodeField.php +++ b/Relay/Node/NodeField.php @@ -12,8 +12,8 @@ namespace Overblog\GraphQLBundle\Relay\Node; use GraphQL\Type\Definition\Config; -use GraphQL\Type\Definition\Type; use Overblog\GraphQLBundle\Definition\Builder\MappingInterface; +use Overblog\GraphQLBundle\Definition\Type; class NodeField implements MappingInterface { diff --git a/Relay/Node/NodeInterfaceType.php b/Relay/Node/NodeInterfaceType.php index d83e6691e..0a0f72247 100644 --- a/Relay/Node/NodeInterfaceType.php +++ b/Relay/Node/NodeInterfaceType.php @@ -12,8 +12,8 @@ namespace Overblog\GraphQLBundle\Relay\Node; use GraphQL\Type\Definition\Config; -use GraphQL\Type\Definition\InterfaceType; -use GraphQL\Type\Definition\Type; +use Overblog\GraphQLBundle\Definition\InterfaceType; +use Overblog\GraphQLBundle\Definition\Type; class NodeInterfaceType extends InterfaceType { diff --git a/Relay/Node/PluralIdentifyingRootField.php b/Relay/Node/PluralIdentifyingRootField.php index 67d1bc09b..16ebbfb02 100644 --- a/Relay/Node/PluralIdentifyingRootField.php +++ b/Relay/Node/PluralIdentifyingRootField.php @@ -12,8 +12,8 @@ namespace Overblog\GraphQLBundle\Relay\Node; use GraphQL\Type\Definition\Config; -use GraphQL\Type\Definition\Type; use Overblog\GraphQLBundle\Definition\Builder\MappingInterface; +use Overblog\GraphQLBundle\Definition\Type; class PluralIdentifyingRootField implements MappingInterface { diff --git a/Request/Executor.php b/Request/Executor.php index c520a0002..fdc14f60b 100644 --- a/Request/Executor.php +++ b/Request/Executor.php @@ -22,6 +22,7 @@ use Overblog\GraphQLBundle\Error\ErrorHandler; use Overblog\GraphQLBundle\Event\Events; use Overblog\GraphQLBundle\Event\ExecutorContextEvent; +use Overblog\GraphQLBundle\Request\Validator\Rule\QueryComplexity; use Symfony\Component\EventDispatcher\EventDispatcherInterface; class Executor @@ -96,6 +97,9 @@ private function executeAndReturnResult(Schema $schema, $requestString, $rootVal try { $source = new Source($requestString ?: '', 'GraphQL request'); $documentAST = GraphQLParser::parse($source); + //todo set using service + QueryComplexity::setRawVariableValues($variableValues); + $validationErrors = DocumentValidator::validate($schema, $documentAST, $this->validationRules); if (!empty($validationErrors)) { diff --git a/Request/Validator/Rule/AbstractQuerySecurity.php b/Request/Validator/Rule/AbstractQuerySecurity.php new file mode 100644 index 000000000..d7c834cb8 --- /dev/null +++ b/Request/Validator/Rule/AbstractQuerySecurity.php @@ -0,0 +1,180 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Request\Validator\Rule; + +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 static 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/Request/Validator/Rule/MaxQueryDepth.php b/Request/Validator/Rule/MaxQueryDepth.php deleted file mode 100644 index 4ee7a4b97..000000000 --- a/Request/Validator/Rule/MaxQueryDepth.php +++ /dev/null @@ -1,153 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Overblog\GraphQLBundle\Request\Validator\Rule; - -use GraphQL\Error; -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\WrappingType; -use GraphQL\Validator\ValidationContext; - -class MaxQueryDepth -{ - const DEFAULT_QUERY_MAX_DEPTH = 100; - const DEFAULT_MAX_COUNT_AFTER_DEPTH_LIMIT = 50; - - private static $maxQueryDepth; - - private $fragments = []; - - public function __construct($maxQueryDepth = self::DEFAULT_QUERY_MAX_DEPTH) - { - $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 static function setMaxQueryDepth($maxQueryDepth) - { - if ($maxQueryDepth < 0) { - throw new \InvalidArgumentException('$maxQueryDepth argument must be greater or equal to 0. '); - } - - self::$maxQueryDepth = (int) $maxQueryDepth; - } - - public static function maxQueryDepthErrorMessage($max, $count) - { - return sprintf('Max query depth should be %d but is greater or equal to %d.', $max, $count); - } - - public function __invoke(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; - } - } - $schema = $context->getSchema(); - $rootTypes = [$schema->getQueryType(), $schema->getMutationType(), $schema->getSubscriptionType()]; - - return 0 !== self::$maxQueryDepth ? [Node::FIELD => $this->getFieldClosure($context, $rootTypes)] : []; - } - - private function getFieldClosure(ValidationContext $context, array $rootTypes) - { - return function (Field $node) use ($context, $rootTypes) { - $parentType = $context->getParentType(); - $type = $this->retrieveCurrentTypeFromValidationContext($context); - $isIntrospectionType = $type && $type->name === '__Schema'; - $isParentRootType = $parentType && in_array($parentType, $rootTypes); - - // check depth only on first rootTypes children and ignore check on introspection query - if ($isParentRootType && !$isIntrospectionType) { - $depth = $node->selectionSet ? - $this->countSelectionDepth( - $node->selectionSet, - self::$maxQueryDepth + static::DEFAULT_MAX_COUNT_AFTER_DEPTH_LIMIT, - 0, - true - ) : - 0 - ; - - if ($depth > self::$maxQueryDepth) { - return new Error(static::maxQueryDepthErrorMessage(self::$maxQueryDepth, $depth), [$node]); - } - } - }; - } - - private function retrieveCurrentTypeFromValidationContext(ValidationContext $context) - { - $type = $context->getType(); - - if ($type instanceof WrappingType) { - $type = $type->getWrappedType(true); - } - - return $type; - } - - private function countSelectionDepth(SelectionSet $selectionSet, $stopCountingAt, $depth = 0, $resetDepthForEachSelection = false) - { - foreach ($selectionSet->selections as $selectionAST) { - if ($depth >= $stopCountingAt) { - break; - } - - $depth = $resetDepthForEachSelection ? 0 : $depth; - - if ($selectionAST instanceof Field) { - $depth = $this->countFieldDepth($selectionAST->selectionSet, $stopCountingAt, $depth); - } elseif ($selectionAST instanceof FragmentSpread) { - $depth = $this->countFragmentDepth($selectionAST, $stopCountingAt, $depth); - } elseif ($selectionAST instanceof InlineFragment) { - $depth = $this->countInlineFragmentDepth($selectionAST->selectionSet, $stopCountingAt, $depth); - } - } - - return $depth; - } - - private function countFieldDepth(SelectionSet $selectionSet = null, $stopCountingAt, $depth) - { - return null === $selectionSet ? $depth : $this->countSelectionDepth($selectionSet, $stopCountingAt, ++$depth); - } - - private function countInlineFragmentDepth(SelectionSet $selectionSet = null, $stopCountingAt, $depth) - { - return null === $selectionSet ? $depth : $this->countSelectionDepth($selectionSet, $stopCountingAt, $depth); - } - - private function countFragmentDepth(FragmentSpread $selectionAST, $stopCountingAt, $depth) - { - $spreadName = $selectionAST->name->value; - if (isset($this->fragments[$spreadName])) { - /** @var FragmentDefinition $fragment */ - $fragment = $this->fragments[$spreadName]; - $depth = $this->countSelectionDepth($fragment->selectionSet, $stopCountingAt, $depth); - } - - return $depth; - } -} diff --git a/Request/Validator/Rule/QueryComplexity.php b/Request/Validator/Rule/QueryComplexity.php new file mode 100644 index 000000000..3b7885126 --- /dev/null +++ b/Request/Validator/Rule/QueryComplexity.php @@ -0,0 +1,223 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Request\Validator\Rule; + +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 +{ + const DEFAULT_QUERY_MAX_COMPLEXITY = self::DISABLED; + + private static $maxQueryComplexity; + + private static $rawVariableValues = []; + + private $variableDefs; + + private $fieldAstAndDefs; + + /** + * @var ValidationContext + */ + private $context; + + public function __construct($maxQueryDepth = self::DEFAULT_QUERY_MAX_COMPLEXITY) + { + $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 static function setMaxQueryComplexity($maxQueryComplexity) + { + self::checkIfGreaterOrEqualToZero('maxQueryComplexity', $maxQueryComplexity); + + self::$maxQueryComplexity = (int) $maxQueryComplexity; + } + + public static function getMaxQueryComplexity() + { + return self::$maxQueryComplexity; + } + + public static function setRawVariableValues(array $rawVariableValues = null) + { + self::$rawVariableValues = $rawVariableValues ?: []; + } + + public static function getRawVariableValues() + { + return self::$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 = \Overblog\GraphQLBundle\Definition\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/Request/Validator/Rule/QueryDepth.php b/Request/Validator/Rule/QueryDepth.php new file mode 100644 index 000000000..223fde96c --- /dev/null +++ b/Request/Validator/Rule/QueryDepth.php @@ -0,0 +1,128 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Request\Validator\Rule; + +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 +{ + const DEFAULT_QUERY_MAX_DEPTH = self::DISABLED; + + /** + * @var int + */ + private static $maxQueryDepth; + + public function __construct($maxQueryDepth = self::DEFAULT_QUERY_MAX_DEPTH) + { + $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 static function setMaxQueryDepth($maxQueryDepth) + { + self::checkIfGreaterOrEqualToZero('maxQueryDepth', $maxQueryDepth); + + self::$maxQueryDepth = (int) $maxQueryDepth; + } + + public static function getMaxQueryDepth() + { + return self::$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/Resolver/Config/FieldsConfigSolution.php b/Resolver/Config/FieldsConfigSolution.php index 30437ca90..41b014439 100644 --- a/Resolver/Config/FieldsConfigSolution.php +++ b/Resolver/Config/FieldsConfigSolution.php @@ -11,6 +11,7 @@ namespace Overblog\GraphQLBundle\Resolver\Config; +use Overblog\GraphQLBundle\Definition\Argument; use Overblog\GraphQLBundle\Definition\Builder\MappingInterface; use Overblog\GraphQLBundle\Error\UserError; use Overblog\GraphQLBundle\Relay\Connection\Output\Connection; @@ -40,7 +41,7 @@ public function __construct( public function solve($values, array &$config = null) { // builder must be last - $fieldsTreated = ['type', 'args', 'argsBuilder', 'deprecationReason', 'builder']; + $fieldsTreated = ['complexity', 'type', 'args', 'argsBuilder', 'deprecationReason', 'builder']; $fieldsDefaultAccess = isset($config['fieldsDefaultAccess']) ? $config['fieldsDefaultAccess'] : null; unset($config['fieldsDefaultAccess']); @@ -52,7 +53,7 @@ public function solve($values, array &$config = null) foreach ($fieldsTreated as $fieldTreated) { if (isset($options[$fieldTreated])) { $method = 'solve'.ucfirst($fieldTreated); - $options = $this->$method($options, $field); + $options = $this->$method($options, $field, $config); } } $options = $this->resolveResolveAndAccessIfNeeded($options); @@ -61,6 +62,28 @@ public function solve($values, array &$config = null) return $values; } + private function solveComplexity($options, $field) + { + $treatedOptions = $options; + + $value = $treatedOptions['complexity']; + + $treatedOptions['complexity'] = function () use ($value) { + $args = func_get_args(); + $complexity = $this->solveUsingExpressionLanguageIfNeeded( + $value, + [ + 'childrenComplexity' => $args[0], + 'args' => new Argument($args[1]), + ] + ); + + return (int) $complexity; + }; + + return $treatedOptions; + } + private function solveBuilder($options, $field) { $builderConfig = isset($options['builderConfig']) ? $options['builderConfig'] : []; diff --git a/Resources/config/services.yml b/Resources/config/services.yml index 223b01005..d4a3688d4 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -1,6 +1,7 @@ parameters: overblog_graphql.default_resolver: [Overblog\GraphQLBundle\Resolver\Resolver, defaultResolveFn] + #todo use tagged services overblog_graphql.configs_mapping: fields: {id: "overblog_graphql.fields_config_solution", method: "solve"} isTypeOf: {id: "overblog_graphql.resolve_callback_config_solution", method: "solve"} @@ -37,10 +38,14 @@ services: - %kernel.debug% - "@overblog_graphql.error_handler" calls: - - ["addValidatorRule", ["@overblog_graphql.request_validator_rule_max_query_depth"]] + - ["addValidatorRule", ["@overblog_graphql.request_validator_rule_query_depth"]] + - ["addValidatorRule", ["@overblog_graphql.request_validator_rule_query_complexity"]] - overblog_graphql.request_validator_rule_max_query_depth: - class: Overblog\GraphQLBundle\Request\Validator\Rule\MaxQueryDepth + overblog_graphql.request_validator_rule_query_depth: + class: Overblog\GraphQLBundle\Request\Validator\Rule\QueryDepth + + overblog_graphql.request_validator_rule_query_complexity: + class: Overblog\GraphQLBundle\Request\Validator\Rule\QueryComplexity overblog_graphql.request_parser: class: Overblog\GraphQLBundle\Request\Parser diff --git a/Tests/Functional/Security/AccessTest.php b/Tests/Functional/Security/AccessTest.php new file mode 100644 index 000000000..429adf811 --- /dev/null +++ b/Tests/Functional/Security/AccessTest.php @@ -0,0 +1,170 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Tests\Functional\Security; + +use Overblog\GraphQLBundle\Tests\Functional\TestCase; + +class AccessTest extends TestCase +{ + const USER_RYAN = 'ryan'; + const USER_ADMIN = 'admin'; + const ANONYMOUS_USER = null; + + private $userNameQuery = 'query MyQuery { user { name } }'; + + private $userRolesQuery = 'query MyQuery { user { roles } }'; + + private $userIsEnabledQuery = 'query MyQuery { user { isEnabled } }'; + + private $userFriendsQuery = << [ + 'user' => [ + 'name' => null, + ], + ], + 'errors' => [ + [ + 'message' => 'Access denied to this field.', + 'locations' => [['line' => 1, 'column' => 24]], + ], + ], + ]; + + $this->assertResponse($this->userNameQuery, $expected, static::ANONYMOUS_USER); + } + + public function testFullyAuthenticatedUserAccessToUserName() + { + $expected = [ + 'data' => [ + 'user' => [ + 'name' => 'Dan', + ], + ], + ]; + + $this->assertResponse($this->userNameQuery, $expected, static::USER_RYAN); + } + + public function testNotAuthenticatedUserAccessToUserRoles() + { + $this->assertResponse($this->userRolesQuery, $this->expectedFailedUserRoles(), static::ANONYMOUS_USER); + } + + public function testAuthenticatedUserAccessToUserRolesWithoutEnoughRights() + { + $this->assertResponse($this->userRolesQuery, $this->expectedFailedUserRoles(), static::USER_RYAN); + } + + public function testUserWithCorrectRightsAccessToUserRoles() + { + $expected = [ + 'data' => [ + 'user' => [ + 'roles' => ['ROLE_USER'], + ], + ], + ]; + + $this->assertResponse($this->userRolesQuery, $expected, static::USER_ADMIN); + } + + public function testUserAccessToUserFriends() + { + $expected = [ + 'data' => [ + 'user' => [ + 'friends' => [ + 'edges' => [ + ['node' => ['name' => 'Nick']], + ['node' => null], + ], + ], + ], + ], + ]; + + $this->assertResponse($this->userFriendsQuery, $expected, static::USER_ADMIN); + } + + public function testUserAccessToUserIsEnabledWithExpressionLanguageEvaluationFailed() + { + $expected = [ + 'data' => [ + 'user' => [ + 'isEnabled' => null, + ], + ], + 'errors' => [ + [ + 'message' => 'Access denied to this field.', + 'locations' => [['line' => 1, 'column' => 24]], + ], + ], + ]; + + $this->assertResponse($this->userIsEnabledQuery, $expected, static::USER_ADMIN); + } + + private function expectedFailedUserRoles() + { + return [ + 'data' => [ + 'user' => [ + 'roles' => [], + ], + ], + ]; + } + + private static function assertResponse($query, array $expected, $username) + { + $client = self::createClientAuthenticated($username); + $client->request('GET', '/', ['query' => $query]); + + $result = $client->getResponse()->getContent(); + + static::assertEquals($expected, json_decode($result, true), $result); + + return $client; + } + + private static function createClientAuthenticated($username) + { + $client = static::createClient(['test_case' => 'access']); + + if ($username) { + $client->setServerParameters([ + 'PHP_AUTH_USER' => $username, + 'PHP_AUTH_PW' => '123', + ]); + } + + return $client; + } +} diff --git a/Tests/Functional/Security/QueryComplexityTest.php b/Tests/Functional/Security/QueryComplexityTest.php new file mode 100644 index 000000000..2c895ea04 --- /dev/null +++ b/Tests/Functional/Security/QueryComplexityTest.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Tests\Functional\Security; + +use Overblog\GraphQLBundle\Tests\Functional\TestCase; + +class QueryComplexityTest extends TestCase +{ + private $userFriendsWithoutLimitQuery = << null, + 'errors' => [ + [ + 'message' => 'Max query complexity should be 10 but got 54.', + ], + ], + ]; + + $this->assertResponse($this->userFriendsWithoutLimitQuery, $expected); + } + + public function testComplexityUnderLimitation() + { + $expected = [ + 'data' => [ + 'user' => [ + 'friends' => [ + 'edges' => [ + ['node' => ['name' => 'Nick']], + ], + ], + ], + ], + ]; + + $this->assertResponse($this->userFriendsWithLimitQuery, $expected); + } + + private static function assertResponse($query, array $expected) + { + $client = static::createClient(['test_case' => 'queryComplexity']); + $client->request('GET', '/', ['query' => $query]); + + $result = $client->getResponse()->getContent(); + + static::assertEquals($expected, json_decode($result, true), $result); + + return $client; + } +} diff --git a/Tests/Functional/app/AppKernel.php b/Tests/Functional/app/AppKernel.php index 415f3aebd..80f843b73 100644 --- a/Tests/Functional/app/AppKernel.php +++ b/Tests/Functional/app/AppKernel.php @@ -28,6 +28,7 @@ public function registerBundles() { return [ new \Symfony\Bundle\FrameworkBundle\FrameworkBundle(), + new \Symfony\Bundle\SecurityBundle\SecurityBundle(), new \Symfony\Bundle\TwigBundle\TwigBundle(), new \Overblog\GraphQLBundle\OverblogGraphQLBundle(), ]; diff --git a/Tests/Functional/app/Resolver/ConnectionResolver.php b/Tests/Functional/app/Resolver/ConnectionResolver.php index e7271b99f..4aa9d3da7 100644 --- a/Tests/Functional/app/Resolver/ConnectionResolver.php +++ b/Tests/Functional/app/Resolver/ConnectionResolver.php @@ -46,7 +46,7 @@ public function friendsResolver($user, $args) public function resolveNode(Edge $edge) { - return $this->allUsers[$edge->node]; + return isset($this->allUsers[$edge->node]) ? $this->allUsers[$edge->node] : null; } public function resolveConnection() diff --git a/Tests/Functional/app/config/access/config.yml b/Tests/Functional/app/config/access/config.yml new file mode 100644 index 000000000..49e0a0c2b --- /dev/null +++ b/Tests/Functional/app/config/access/config.yml @@ -0,0 +1,37 @@ +imports: + - { resource: ../config.yml } + - { resource: ../connection/services.yml } + +overblog_graphql: + definitions: + schema: + query: Query + mutation: ~ + mappings: + types: + - + type: yml + dir: %kernel.root_dir%/config/connection/mapping + - + type: yml + dir: %kernel.root_dir%/config/access/mapping + +security: + providers: + in_memory: + memory: + users: + ryan: + password: 123 + roles: 'ROLE_USER' + admin: + password: 123 + roles: 'ROLE_ADMIN' + encoders: + Symfony\Component\Security\Core\User\User: plaintext + firewalls: + graph: + pattern: ^/ + http_basic: ~ + stateless: true + anonymous: true diff --git a/Tests/Functional/app/config/access/mapping/access.types.yml b/Tests/Functional/app/config/access/mapping/access.types.yml new file mode 100644 index 000000000..905b39dd2 --- /dev/null +++ b/Tests/Functional/app/config/access/mapping/access.types.yml @@ -0,0 +1,15 @@ +User: + config: + fields: + name: + access: "@=isFullyAuthenticated()" + roles: + type: '[String]' + access: "@=hasRole('ROLE_ADMIN')" + resolve: ['ROLE_USER'] + isEnabled: + type: Boolean + access: "@=wrong expression function" + resolve: true + friends: + access: "@=object === 1" diff --git a/Tests/Functional/app/config/connection/config.yml b/Tests/Functional/app/config/connection/config.yml index 73851062f..6b83910c6 100644 --- a/Tests/Functional/app/config/connection/config.yml +++ b/Tests/Functional/app/config/connection/config.yml @@ -1,14 +1,6 @@ imports: - { resource: ../config.yml } - -services: - overblog_graphql.test.resolver.node: - class: Overblog\GraphQLBundle\Tests\Functional\app\Resolver\ConnectionResolver - tags: - - { name: "overblog_graphql.resolver", alias: "friends", method: "friendsResolver" } - - { name: "overblog_graphql.resolver", alias: "node", method: "resolveNode" } - - { name: "overblog_graphql.resolver", alias: "query", method: "resolveQuery" } - - { name: "overblog_graphql.resolver", alias: "connection", method: "resolveConnection" } + - { resource: services.yml } overblog_graphql: definitions: diff --git a/Tests/Functional/app/config/connection/services.yml b/Tests/Functional/app/config/connection/services.yml new file mode 100644 index 000000000..b792daed1 --- /dev/null +++ b/Tests/Functional/app/config/connection/services.yml @@ -0,0 +1,8 @@ +services: + overblog_graphql.test.resolver.node: + class: Overblog\GraphQLBundle\Tests\Functional\app\Resolver\ConnectionResolver + tags: + - { name: "overblog_graphql.resolver", alias: "friends", method: "friendsResolver" } + - { name: "overblog_graphql.resolver", alias: "node", method: "resolveNode" } + - { name: "overblog_graphql.resolver", alias: "query", method: "resolveQuery" } + - { name: "overblog_graphql.resolver", alias: "connection", method: "resolveConnection" } diff --git a/Tests/Functional/app/config/queryComplexity/config.yml b/Tests/Functional/app/config/queryComplexity/config.yml new file mode 100644 index 000000000..90de17b48 --- /dev/null +++ b/Tests/Functional/app/config/queryComplexity/config.yml @@ -0,0 +1,19 @@ +imports: + - { resource: ../config.yml } + - { resource: ../connection/services.yml } + +overblog_graphql: + security: + query_max_complexity: 10 + definitions: + schema: + query: Query + mutation: ~ + mappings: + types: + - + type: yml + dir: %kernel.root_dir%/config/connection/mapping + - + type: yml + dir: %kernel.root_dir%/config/queryComplexity/mapping diff --git a/Tests/Functional/app/config/queryComplexity/mapping/connectionWithComplexity.types.yml b/Tests/Functional/app/config/queryComplexity/mapping/connectionWithComplexity.types.yml new file mode 100644 index 000000000..b35934e62 --- /dev/null +++ b/Tests/Functional/app/config/queryComplexity/mapping/connectionWithComplexity.types.yml @@ -0,0 +1,10 @@ +User: + type: object + config: + fields: + name: + type: String + friends: + type: friendConnection + argsBuilder: ConnectionArgs + complexity: "@=(!args['first'] ? 50 : 1) + childrenComplexity" diff --git a/Tests/Request/Validator/Rule/AbstractQuerySecurityTest.php b/Tests/Request/Validator/Rule/AbstractQuerySecurityTest.php new file mode 100644 index 000000000..41e1a0efe --- /dev/null +++ b/Tests/Request/Validator/Rule/AbstractQuerySecurityTest.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Tests\Request\Validator\Rule; + +use GraphQL\FormattedError; +use GraphQL\Language\Parser; +use GraphQL\Type\Introspection; +use GraphQL\Validator\DocumentValidator; +use Overblog\GraphQLBundle\Request\Validator\Rule\AbstractQuerySecurity; + +abstract class AbstractQuerySecurityTest extends \PHPUnit_Framework_TestCase +{ + /** + * @param $max + * + * @return AbstractQuerySecurity + */ + abstract protected function createRule($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->createRule(-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( + Schema::buildSchema(), + Parser::parse($queryString), + [$this->createRule($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/Request/Validator/Rule/QueryComplexityTest.php b/Tests/Request/Validator/Rule/QueryComplexityTest.php new file mode 100644 index 000000000..5fb611f6f --- /dev/null +++ b/Tests/Request/Validator/Rule/QueryComplexityTest.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Overblog\GraphQLBundle\Tests\Request\Validator\Rule; + +use Overblog\GraphQLBundle\Request\Validator\Rule\QueryComplexity; + +class QueryComplexityTest extends AbstractQuerySecurityTest +{ + /** + * @param $max + * @param $count + * + * @return string + */ + protected function getErrorMessage($max, $count) + { + return QueryComplexity::maxQueryComplexityErrorMessage($max, $count); + } + + /** + * @param $maxDepth + * + * @return QueryComplexity + */ + protected function createRule($maxDepth) + { + return new QueryComplexity($maxDepth); + } + + 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 } } }'; + + QueryComplexity::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/Request/Validator/Rule/MaxQueryDepthTest.php b/Tests/Request/Validator/Rule/QueryDepthTest.php similarity index 69% rename from Tests/Request/Validator/Rule/MaxQueryDepthTest.php rename to Tests/Request/Validator/Rule/QueryDepthTest.php index e660307bd..f78858a5e 100644 --- a/Tests/Request/Validator/Rule/MaxQueryDepthTest.php +++ b/Tests/Request/Validator/Rule/QueryDepthTest.php @@ -11,15 +11,31 @@ namespace Overblog\GraphQLBundle\Tests\Request\Validator\Rule; -use GraphQL\FormattedError; -use GraphQL\Language\Parser; -use GraphQL\Language\SourceLocation; -use GraphQL\Type\Introspection; -use GraphQL\Validator\DocumentValidator; -use Overblog\GraphQLBundle\Request\Validator\Rule\MaxQueryDepth; - -class MaxQueryDepthTest extends \PHPUnit_Framework_TestCase +use Overblog\GraphQLBundle\Request\Validator\Rule\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 createRule($maxDepth) + { + return new QueryDepth($maxDepth); + } + /** * @param $queryDepth * @param int $maxQueryDepth @@ -53,18 +69,19 @@ public function testInlineFragmentQueries($queryDepth, $maxQueryDepth = 7, $expe $this->assertDocumentValidator($this->buildRecursiveUsingInlineFragmentQuery($queryDepth), $maxQueryDepth, $expectedErrors); } - public function testIgnoreIntrospectionQuery() + public function testComplexityIntrospectionQuery() { - $this->assertDocumentValidator(Introspection::getIntrospectionQuery(true), 1); + $this->assertIntrospectionQuery(7); } - /** - * @expectedException \InvalidArgumentException - * @expectedExceptionMessage $maxQueryDepth argument must be greater or equal to 0. - */ - public function testMaxQueryDepthMustBeGreaterOrEqualTo0() + public function testIntrospectionTypeMetaFieldQuery() { - new MaxQueryDepth(-1); + $this->assertIntrospectionTypeMetaFieldQuery(1); + } + + public function testTypeNameMetaFieldQuery() + { + $this->assertTypeNameMetaFieldQuery(1); } public function queryDataProvider() @@ -85,18 +102,13 @@ public function queryDataProvider() [$this->createFormattedError(8, 10)], ], // failed because depth over limit (8) [ - 60, - 8, - [$this->createFormattedError(8, 58)], - ], // failed because depth over limit (8) and stop count at 58 + 20, + 15, + [$this->createFormattedError(15, 20)], + ], // failed because depth over limit (15) ]; } - private function createFormattedError($max, $count) - { - return FormattedError::create(MaxQueryDepth::maxQueryDepthErrorMessage($max, $count), [new SourceLocation(1, 17)]); - } - private function buildRecursiveQuery($depth) { $query = sprintf('query MyQuery { human%s }', $this->buildRecursiveQueryPart($depth)); @@ -113,6 +125,7 @@ private function buildRecursiveUsingFragmentQuery($depth) return $query; } + private function buildRecursiveUsingInlineFragmentQuery($depth) { $query = sprintf( @@ -127,7 +140,7 @@ private function buildRecursiveQueryPart($depth) { $templates = [ 'human' => ' { firstName%s } ', - 'dog' => ' dog { name%s } ', + 'dog' => ' dogs { name%s } ', ]; $part = $templates['human']; @@ -142,17 +155,4 @@ private function buildRecursiveQueryPart($depth) return $part; } - - private function assertDocumentValidator($queryString, $depth, array $expectedErrors = []) - { - $errors = DocumentValidator::validate( - Schema::buildSchema(), - Parser::parse($queryString), - [new MaxQueryDepth($depth)] - ); - - $this->assertEquals($expectedErrors, array_map(['GraphQL\Error', 'formatError'], $errors), $queryString); - - return $errors; - } } diff --git a/Tests/Request/Validator/Rule/Schema.php b/Tests/Request/Validator/Rule/Schema.php index 5c9a03f48..02bf5fa9d 100644 --- a/Tests/Request/Validator/Rule/Schema.php +++ b/Tests/Request/Validator/Rule/Schema.php @@ -12,8 +12,8 @@ namespace Overblog\GraphQLBundle\Tests\Request\Validator\Rule; use GraphQL\Schema as GraphQLSchema; -use GraphQL\Type\Definition\ObjectType; -use GraphQL\Type\Definition\Type; +use Overblog\GraphQLBundle\Definition\ObjectType; +use Overblog\GraphQLBundle\Definition\Type; class Schema { @@ -34,9 +34,6 @@ public static function buildSchema() return self::$schema; } - static::buildHumanType(); - static::buildDogType(); - self::$schema = new GraphQLSchema(static::buildQueryRootType()); return self::$schema; @@ -53,6 +50,7 @@ public static function buildQueryRootType() 'fields' => [ 'human' => [ 'type' => self::buildHumanType(), + 'args' => ['name' => ['type' => Type::string()]], ], ], ]); @@ -71,7 +69,7 @@ public static function buildHumanType() 'name' => 'Human', 'fields' => [ 'firstName' => ['type' => Type::nonNull(Type::string())], - 'Dog' => [ + 'dogs' => [ 'type' => function () { return Type::nonNull( Type::listOf( @@ -79,6 +77,12 @@ public static function buildHumanType() ) ); }, + 'complexity' => function ($childrenComplexity, $args) { + $complexity = isset($args['name']) ? 1 : 10; + + return $childrenComplexity + $complexity; + }, + 'args' => ['name' => ['type' => Type::string()]], ], ], ] diff --git a/composer.json b/composer.json index 2d1340ad5..262f41454 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "symfony/expression-language": "^2.7|^3.0", "symfony/options-resolver": "^2.7|^3.0", "doctrine/doctrine-cache-bundle": "^1.2", - "webonyx/graphql-php": "^0.5.8", + "webonyx/graphql-php": "^0.5.9", "symfony/property-access": "^2.7|^3.0" }, "suggest": {