Skip to content

Add query security document validation rules #32

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 15, 2016
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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,42 @@ header('Content-Type: application/json');
echo json_encode($result);
```

### Security

#### Query Complexity Analysis

This is a PHP port of [Query Complexity Analysis](http://sangria-graphql.org/learn/#query-complexity-analysis) in Sangria implementation.
Introspection query with description max complexity is **109**.

This document validator rule is disabled by default. Here an example to enabled it:

```php
use GraphQL\GraphQL;

/** @var \GraphQL\Validator\Rules\QueryComplexity $queryComplexity */
$queryComplexity = DocumentValidator::getRule('QueryComplexity');
$queryComplexity->setMaxQueryComplexity($maxQueryComplexity = 110);

GraphQL::execute(/*...*/);
```

#### Limiting Query Depth

This is a PHP port of [Limiting Query Depth](http://sangria-graphql.org/learn/#limiting-query-depth) in Sangria implementation.
Introspection query with description max depth is **7**.

This document validator rule is disabled by default. Here an example to enabled it:

```php
use GraphQL\GraphQL;

/** @var \GraphQL\Validator\Rules\QueryDepth $queryDepth */
$queryDepth = DocumentValidator::getRule('QueryDepth');
$queryDepth->setMaxQueryDepth($maxQueryDepth = 10);

GraphQL::execute(/*...*/);
```

### More Examples
Make sure to check [tests](https://github.com/webonyx/graphql-php/tree/master/tests) for more usage examples.

Expand Down
14 changes: 6 additions & 8 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@
"bin-dir": "bin"
},
"autoload": {
"classmap": [
"src/"
]
"psr-4": {
"GraphQL\\": "src/"
}
},
"autoload-dev": {
"classmap": [
"tests/"
],
"files": [
]
"psr-4": {
"GraphQL\\Tests\\": "tests/"
}
}
}
34 changes: 34 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>

<phpunit backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
syntaxCheck="false"
bootstrap="vendor/autoload.php"
>
<testsuites>
<testsuite name="webonyx/graphql-php Test Suite">
<directory>./tests/</directory>
</testsuite>
</testsuites>

<filter>
<whitelist>
<directory>./</directory>
<exclude>
<directory>./tests</directory>
<directory>./vendor</directory>
</exclude>
</whitelist>
</filter>

<php>
<ini name="error_reporting" value="E_ALL"/>
</php>

</phpunit>
6 changes: 6 additions & 0 deletions src/GraphQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use GraphQL\Language\Parser;
use GraphQL\Language\Source;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Validator\Rules\QueryComplexity;

class GraphQL
{
Expand Down Expand Up @@ -35,6 +36,11 @@ public static function executeAndReturnResult(Schema $schema, $requestString, $r
try {
$source = new Source($requestString ?: '', 'GraphQL request');
$documentAST = Parser::parse($source);

/** @var QueryComplexity $queryComplexity */
$queryComplexity = DocumentValidator::getRule('QueryComplexity');
$queryComplexity->setRawVariableValues($variableValues);

$validationErrors = DocumentValidator::validate($schema, $documentAST);

if (!empty($validationErrors)) {
Expand Down
18 changes: 18 additions & 0 deletions src/Type/Definition/FieldDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

class FieldDefinition
{
const DEFAULT_COMPLEXITY_FN = 'GraphQL\Type\Definition\FieldDefinition::defaultComplexity';

/**
* @var string
*/
Expand Down Expand Up @@ -72,6 +74,7 @@ public static function getDefinition()
'map' => Config::CALLBACK,
'description' => Config::STRING,
'deprecationReason' => Config::STRING,
'complexity' => Config::CALLBACK,
]);
}

Expand Down Expand Up @@ -113,6 +116,8 @@ protected function __construct(array $config)
$this->deprecationReason = isset($config['deprecationReason']) ? $config['deprecationReason'] : null;

$this->config = $config;

$this->complexityFn = isset($config['complexity']) ? $config['complexity'] : static::DEFAULT_COMPLEXITY_FN;
}

/**
Expand Down Expand Up @@ -141,4 +146,17 @@ public function getType()
}
return $this->resolvedType;
}

/**
* @return callable|\Closure
*/
public function getComplexityFn()
{
return $this->complexityFn;
}

public static function defaultComplexity($childrenComplexity)
{
return $childrenComplexity + 1;
}
}
108 changes: 70 additions & 38 deletions src/Validator/DocumentValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,59 +34,91 @@
use GraphQL\Validator\Rules\OverlappingFieldsCanBeMerged;
use GraphQL\Validator\Rules\PossibleFragmentSpreads;
use GraphQL\Validator\Rules\ProvidedNonNullArguments;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\Rules\QueryDepth;
use GraphQL\Validator\Rules\ScalarLeafs;
use GraphQL\Validator\Rules\VariablesAreInputTypes;
use GraphQL\Validator\Rules\VariablesInAllowedPosition;

class DocumentValidator
{
private static $allRules;
private static $rules = [];

static function allRules()
private static $defaultRules;

private static $initRules = false;

public static function allRules()
{
if (!self::$initRules) {
self::$rules = array_merge(static::defaultRules(), self::$rules);
self::$initRules = true;
}

return self::$rules;
}

public static function defaultRules()
{
if (null === self::$allRules) {
self::$allRules = [
if (null === self::$defaultRules) {
self::$defaultRules = [
// new UniqueOperationNames,
// new LoneAnonymousOperation,
new KnownTypeNames,
new FragmentsOnCompositeTypes,
new VariablesAreInputTypes,
new ScalarLeafs,
new FieldsOnCorrectType,
'KnownTypeNames' => new KnownTypeNames(),
'FragmentsOnCompositeTypes' => new FragmentsOnCompositeTypes(),
'VariablesAreInputTypes' => new VariablesAreInputTypes(),
'ScalarLeafs' => new ScalarLeafs(),
'FieldsOnCorrectType' => new FieldsOnCorrectType(),
// new UniqueFragmentNames,
new KnownFragmentNames,
new NoUnusedFragments,
new PossibleFragmentSpreads,
new NoFragmentCycles,
new NoUndefinedVariables,
new NoUnusedVariables,
new KnownDirectives,
new KnownArgumentNames,
'KnownFragmentNames' => new KnownFragmentNames(),
'NoUnusedFragments' => new NoUnusedFragments(),
'PossibleFragmentSpreads' => new PossibleFragmentSpreads(),
'NoFragmentCycles' => new NoFragmentCycles(),
'NoUndefinedVariables' => new NoUndefinedVariables(),
'NoUnusedVariables' => new NoUnusedVariables(),
'KnownDirectives' => new KnownDirectives(),
'KnownArgumentNames' => new KnownArgumentNames(),
// new UniqueArgumentNames,
new ArgumentsOfCorrectType,
new ProvidedNonNullArguments,
new DefaultValuesOfCorrectType,
new VariablesInAllowedPosition,
new OverlappingFieldsCanBeMerged,
'ArgumentsOfCorrectType' => new ArgumentsOfCorrectType(),
'ProvidedNonNullArguments' => new ProvidedNonNullArguments(),
'DefaultValuesOfCorrectType' => new DefaultValuesOfCorrectType(),
'VariablesInAllowedPosition' => new VariablesInAllowedPosition(),
'OverlappingFieldsCanBeMerged' => new OverlappingFieldsCanBeMerged(),
// Query Security
'QueryDepth' => new QueryDepth(QueryDepth::DISABLED), // default disabled
'QueryComplexity' => new QueryComplexity(QueryComplexity::DISABLED), // default disabled
];
}
return self::$allRules;

return self::$defaultRules;
}

public static function getRule($name)
{
$rules = static::allRules();

return isset($rules[$name]) ? $rules[$name] : null ;
}

public static function addRule($name, callable $rule)
{
self::$rules[$name] = $rule;
}

public static function validate(Schema $schema, Document $ast, array $rules = null)
{
$errors = self::visitUsingRules($schema, $ast, $rules ?: self::allRules());
$errors = static::visitUsingRules($schema, $ast, $rules ?: static::allRules());
return $errors;
}

static function isError($value)
public static function isError($value)
{
return is_array($value)
? count(array_filter($value, function($item) { return $item instanceof \Exception;})) === count($value)
: $value instanceof \Exception;
}

static function append(&$arr, $items)
public static function append(&$arr, $items)
{
if (is_array($items)) {
$arr = array_merge($arr, $items);
Expand All @@ -96,7 +128,7 @@ static function append(&$arr, $items)
return $arr;
}

static function isValidLiteralValue($valueAST, Type $type)
public static function isValidLiteralValue($valueAST, Type $type)
{
// A value can only be not provided if the type is nullable.
if (!$valueAST) {
Expand All @@ -105,7 +137,7 @@ static function isValidLiteralValue($valueAST, Type $type)

// Unwrap non-null.
if ($type instanceof NonNull) {
return self::isValidLiteralValue($valueAST, $type->getWrappedType());
return static::isValidLiteralValue($valueAST, $type->getWrappedType());
}

// This function only tests literals, and assumes variables will provide
Expand All @@ -123,13 +155,13 @@ static function isValidLiteralValue($valueAST, Type $type)
$itemType = $type->getWrappedType();
if ($valueAST instanceof ListValue) {
foreach($valueAST->values as $itemAST) {
if (!self::isValidLiteralValue($itemAST, $itemType)) {
if (!static::isValidLiteralValue($itemAST, $itemType)) {
return false;
}
}
return true;
} else {
return self::isValidLiteralValue($valueAST, $itemType);
return static::isValidLiteralValue($valueAST, $itemType);
}
}

Expand Down Expand Up @@ -157,7 +189,7 @@ static function isValidLiteralValue($valueAST, Type $type)
}
}
foreach ($fieldASTs as $fieldAST) {
if (empty($fields[$fieldAST->name->value]) || !self::isValidLiteralValue($fieldAST->value, $fields[$fieldAST->name->value]->getType())) {
if (empty($fields[$fieldAST->name->value]) || !static::isValidLiteralValue($fieldAST->value, $fields[$fieldAST->name->value]->getType())) {
return false;
}
}
Expand Down Expand Up @@ -231,8 +263,8 @@ public static function visitUsingRules(Schema $schema, Document $documentAST, ar
} else if ($result->doBreak) {
$instances[$i] = null;
}
} else if ($result && self::isError($result)) {
self::append($errors, $result);
} else if ($result && static::isError($result)) {
static::append($errors, $result);
for ($j = $i - 1; $j >= 0; $j--) {
$leaveFn = Visitor::getVisitFn($instances[$j], true, $node->kind);
if ($leaveFn) {
Expand All @@ -243,8 +275,8 @@ public static function visitUsingRules(Schema $schema, Document $documentAST, ar
if ($result->doBreak) {
$instances[$j] = null;
}
} else if (self::isError($result)) {
self::append($errors, $result);
} else if (static::isError($result)) {
static::append($errors, $result);
} else if ($result !== null) {
throw new \Exception("Config cannot edit document.");
}
Expand Down Expand Up @@ -294,8 +326,8 @@ public static function visitUsingRules(Schema $schema, Document $documentAST, ar
if ($result->doBreak) {
$instances[$i] = null;
}
} else if (self::isError($result)) {
self::append($errors, $result);
} else if (static::isError($result)) {
static::append($errors, $result);
} else if ($result !== null) {
throw new \Exception("Config cannot edit document.");
}
Expand All @@ -309,7 +341,7 @@ public static function visitUsingRules(Schema $schema, Document $documentAST, ar
// Visit the whole document with instances of all provided rules.
$allRuleInstances = [];
foreach ($rules as $rule) {
$allRuleInstances[] = $rule($context);
$allRuleInstances[] = call_user_func_array($rule, [$context]);
}
$visitInstances($documentAST, $allRuleInstances);

Expand Down
Loading