diff --git a/.travis.yml b/.travis.yml index 9da8c0867..2cee515b9 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 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 --coverage-clover=coverage.clover; else vendor/bin/phpunit; 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/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 12dc200d0..a53dd65fa 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -89,6 +89,24 @@ public function getConfigTreeBuilder() ->end() ->end() ->end() + ->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() + ->end() + ->end() + ->end() ->end(); return $treeBuilder; diff --git a/DependencyInjection/OverblogGraphQLExtension.php b/DependencyInjection/OverblogGraphQLExtension.php index d2dc99c3c..39dc983d3 100644 --- a/DependencyInjection/OverblogGraphQLExtension.php +++ b/DependencyInjection/OverblogGraphQLExtension.php @@ -35,6 +35,7 @@ public function load(array $configs, ContainerBuilder $container) $this->setSchemaArguments($config, $container); $this->setErrorHandlerArguments($config, $container); $this->setGraphiQLTemplate($config, $container); + $this->setSecurity($config, $container); } public function prepend(ContainerBuilder $container) @@ -49,6 +50,17 @@ public function prepend(ContainerBuilder $container) $typesExtension->containerPrependExtensionConfig($config, $container); } + private function setSecurity(array $config, ContainerBuilder $container) + { + if (isset($config['security']['query_max_depth'])) { + $container + ->getDefinition($this->getAlias().'.request_validator_rule_max_query_depth') + ->addMethodCall('setMaxQueryDepth', [$config['security']['query_max_depth']]) + ->setPublic(true) + ; + } + } + private function setGraphiQLTemplate(array $config, ContainerBuilder $container) { if (isset($config['templates']['graphiql'])) { diff --git a/Request/BatchParser.php b/Request/BatchParser.php index 9aefdf43f..c2fab5f1c 100644 --- a/Request/BatchParser.php +++ b/Request/BatchParser.php @@ -18,6 +18,7 @@ class BatchParser implements ParserInterface { /** * @param Request $request + * * @return array */ public function parse(Request $request) @@ -29,7 +30,7 @@ public function parse(Request $request) throw new BadRequestHttpException('Must provide at least one valid query.'); } - foreach($data as $i => &$entry) { + foreach ($data as $i => &$entry) { if (empty($entry[static::PARAM_QUERY]) || !is_string($entry[static::PARAM_QUERY])) { throw new BadRequestHttpException(sprintf('No valid query found in node "%s"', $i)); } diff --git a/Request/Executor.php b/Request/Executor.php index f3760b0e5..c520a0002 100644 --- a/Request/Executor.php +++ b/Request/Executor.php @@ -11,8 +11,14 @@ namespace Overblog\GraphQLBundle\Request; +use GraphQL\Error; +use GraphQL\Executor\ExecutionResult; +use GraphQL\Executor\Executor as GraphQLExecutor; use GraphQL\GraphQL; +use GraphQL\Language\Parser as GraphQLParser; +use GraphQL\Language\Source; use GraphQL\Schema; +use GraphQL\Validator\DocumentValidator; use Overblog\GraphQLBundle\Error\ErrorHandler; use Overblog\GraphQLBundle\Event\Events; use Overblog\GraphQLBundle\Event\ExecutorContextEvent; @@ -23,22 +29,31 @@ class Executor private $schema; /** - * @var EventDispatcherInterface + * @var EventDispatcherInterface|null */ private $dispatcher; /** @var bool */ private $throwException; - /** @var ErrorHandler */ + /** @var ErrorHandler|null */ private $errorHandler; - public function __construct(Schema $schema, EventDispatcherInterface $dispatcher, $throwException, ErrorHandler $errorHandler) + /** @var callable[] */ + private $validationRules; + + public function __construct(Schema $schema, EventDispatcherInterface $dispatcher = null, $throwException = false, ErrorHandler $errorHandler = null) { $this->schema = $schema; $this->dispatcher = $dispatcher; $this->throwException = (bool) $throwException; $this->errorHandler = $errorHandler; + $this->validationRules = DocumentValidator::allRules(); + } + + public function addValidatorRule(callable $validatorRule) + { + $this->validationRules[] = $validatorRule; } /** @@ -55,19 +70,41 @@ public function setThrowException($throwException) public function execute(array $data, array $context = []) { - $event = new ExecutorContextEvent($context); - $this->dispatcher->dispatch(Events::EXECUTOR_CONTEXT, $event); + if (null !== $this->dispatcher) { + $event = new ExecutorContextEvent($context); + $this->dispatcher->dispatch(Events::EXECUTOR_CONTEXT, $event); + $context = $event->getExecutorContext(); + } - $executionResult = GraphQL::executeAndReturnResult( + $executionResult = $this->executeAndReturnResult( $this->schema, isset($data['query']) ? $data['query'] : null, - $event->getExecutorContext(), + $context, $data['variables'], $data['operationName'] ); - $this->errorHandler->handleErrors($executionResult, $this->throwException); + if (null !== $this->errorHandler) { + $this->errorHandler->handleErrors($executionResult, $this->throwException); + } return $executionResult; } + + private function executeAndReturnResult(Schema $schema, $requestString, $rootValue = null, $variableValues = null, $operationName = null) + { + try { + $source = new Source($requestString ?: '', 'GraphQL request'); + $documentAST = GraphQLParser::parse($source); + $validationErrors = DocumentValidator::validate($schema, $documentAST, $this->validationRules); + + if (!empty($validationErrors)) { + return new ExecutionResult(null, $validationErrors); + } + + return GraphQLExecutor::execute($schema, $documentAST, $rootValue, $variableValues, $operationName); + } catch (Error $e) { + return new ExecutionResult(null, [$e]); + } + } } diff --git a/Request/Parser.php b/Request/Parser.php index 011216866..6bffd78e6 100644 --- a/Request/Parser.php +++ b/Request/Parser.php @@ -18,6 +18,7 @@ class Parser implements ParserInterface { /** * @param Request $request + * * @return array */ public function parse(Request $request) diff --git a/Request/ParserInterface.php b/Request/ParserInterface.php index f4bc49578..13b10c2aa 100644 --- a/Request/ParserInterface.php +++ b/Request/ParserInterface.php @@ -16,7 +16,7 @@ interface ParserInterface { - const CONTENT_TYPE_GRAPHQL ='application/graphql'; + const CONTENT_TYPE_GRAPHQL = 'application/graphql'; const CONTENT_TYPE_JSON = 'application/json'; const CONTENT_TYPE_FORM = 'application/x-www-form-urlencoded'; const CONTENT_TYPE_FORM_DATA = 'multipart/form-data'; diff --git a/Request/Validator/Rule/MaxQueryDepth.php b/Request/Validator/Rule/MaxQueryDepth.php new file mode 100644 index 000000000..4ee7a4b97 --- /dev/null +++ b/Request/Validator/Rule/MaxQueryDepth.php @@ -0,0 +1,153 @@ + + * + * 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/Resources/config/services.yml b/Resources/config/services.yml index 5d860817e..223b01005 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -36,6 +36,11 @@ services: - "@event_dispatcher" - %kernel.debug% - "@overblog_graphql.error_handler" + calls: + - ["addValidatorRule", ["@overblog_graphql.request_validator_rule_max_query_depth"]] + + overblog_graphql.request_validator_rule_max_query_depth: + class: Overblog\GraphQLBundle\Request\Validator\Rule\MaxQueryDepth overblog_graphql.request_parser: class: Overblog\GraphQLBundle\Request\Parser diff --git a/Tests/Functional/Controller/GraphControllerTest.php b/Tests/Functional/Controller/GraphControllerTest.php index 6836b7947..55634d5d4 100644 --- a/Tests/Functional/Controller/GraphControllerTest.php +++ b/Tests/Functional/Controller/GraphControllerTest.php @@ -142,7 +142,7 @@ public function testEndpointActionWithOperationName() { $client = static::createClient(['test_case' => 'connection']); - $query = $this->friendsQuery . "\n" .$this->friendsTotalCountQuery; + $query = $this->friendsQuery."\n".$this->friendsTotalCountQuery; $client->request('POST', '/', ['query' => $query, 'operationName' => 'FriendsQuery'], [], ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); $result = $client->getResponse()->getContent(); diff --git a/Tests/Request/Validator/Rule/MaxQueryDepthTest.php b/Tests/Request/Validator/Rule/MaxQueryDepthTest.php new file mode 100644 index 000000000..e660307bd --- /dev/null +++ b/Tests/Request/Validator/Rule/MaxQueryDepthTest.php @@ -0,0 +1,158 @@ + + * + * 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\Language\SourceLocation; +use GraphQL\Type\Introspection; +use GraphQL\Validator\DocumentValidator; +use Overblog\GraphQLBundle\Request\Validator\Rule\MaxQueryDepth; + +class MaxQueryDepthTest extends \PHPUnit_Framework_TestCase +{ + /** + * @param $queryDepth + * @param int $maxQueryDepth + * @param array $expectedErrors + * @dataProvider queryDataProvider + */ + public function testSimpleQueries($queryDepth, $maxQueryDepth = 7, $expectedErrors = []) + { + $this->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 testIgnoreIntrospectionQuery() + { + $this->assertDocumentValidator(Introspection::getIntrospectionQuery(true), 1); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage $maxQueryDepth argument must be greater or equal to 0. + */ + public function testMaxQueryDepthMustBeGreaterOrEqualTo0() + { + new MaxQueryDepth(-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) + [ + 60, + 8, + [$this->createFormattedError(8, 58)], + ], // failed because depth over limit (8) and stop count at 58 + ]; + } + + 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)); + + 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' => ' dog { 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; + } + + 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 new file mode 100644 index 000000000..5c9a03f48 --- /dev/null +++ b/Tests/Request/Validator/Rule/Schema.php @@ -0,0 +1,110 @@ + + * + * 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\Schema as GraphQLSchema; +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\Type; + +class Schema +{ + private static $schema; + + private static $dogType; + + private static $humanType; + + private static $queryRootType; + + /** + * @return GraphQLSchema + */ + public static function buildSchema() + { + if (null !== self::$schema) { + return self::$schema; + } + + static::buildHumanType(); + static::buildDogType(); + + self::$schema = new GraphQLSchema(static::buildQueryRootType()); + + return self::$schema; + } + + public static function buildQueryRootType() + { + if (null !== self::$queryRootType) { + return self::$queryRootType; + } + + self::$queryRootType = new ObjectType([ + 'name' => 'QueryRoot', + 'fields' => [ + 'human' => [ + 'type' => self::buildHumanType(), + ], + ], + ]); + + 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())], + 'Dog' => [ + 'type' => function () { + return Type::nonNull( + Type::listOf( + Type::nonNull(self::buildDogType()) + ) + ); + }, + ], + ], + ] + ); + + 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; + } +}