Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions DependencyInjection/OverblogGraphQLExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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'])) {
Expand Down
3 changes: 2 additions & 1 deletion Request/BatchParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class BatchParser implements ParserInterface
{
/**
* @param Request $request
*
* @return array
*/
public function parse(Request $request)
Expand All @@ -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));
}
Expand Down
53 changes: 45 additions & 8 deletions Request/Executor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

/**
Expand All @@ -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]);
}
}
}
1 change: 1 addition & 0 deletions Request/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class Parser implements ParserInterface
{
/**
* @param Request $request
*
* @return array
*/
public function parse(Request $request)
Expand Down
2 changes: 1 addition & 1 deletion Request/ParserInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
153 changes: 153 additions & 0 deletions Request/Validator/Rule/MaxQueryDepth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

/*
* This file is part of the OverblogGraphQLBundle package.
*
* (c) Overblog <http://github.com/overblog/>
*
* 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;
}
}
5 changes: 5 additions & 0 deletions Resources/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Tests/Functional/Controller/GraphControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading