Skip to content

Commit

Permalink
Restrict Depth Limit (#55)
Browse files Browse the repository at this point in the history
* refactor: restrict depth limit of queries and mutations

* refactor: 10 limit instead of 20

* refactor: add docs for depth limit
  • Loading branch information
e-lobo authored Feb 4, 2022
1 parent 8a4b42e commit 9e0c9e2
Show file tree
Hide file tree
Showing 3 changed files with 194 additions and 6 deletions.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ Result
</details>
<hr/>

## Restrict Query/Mutation depth

Query/Mutation is restricted by default to 10.

You can change the depth limit by setting the site config `frappe_graphql_depth_limit: 15`.

<hr/>

## Subscriptions
Get notified instantly of the updates via existing frappe's SocketIO. Please read more on the implementation details [here](./docs/subscriptions.md)
<hr/>
Expand Down Expand Up @@ -223,7 +231,8 @@ def is_introspection_disabled():
return not cint(frappe.local.conf.get("developer_mode")) and \
not cint(frappe.local.conf.get("enable_introspection_in_production"))
```
<hr>
<hr/>

## Introspection in Production
Introspection is disabled by default in production mode. You can enable by setting the site config `enable_introspection_in_production: 1`.

Expand Down
25 changes: 20 additions & 5 deletions frappe_graphql/api.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
from graphql import GraphQLError
from graphql import GraphQLError, validate, parse
from typing import List

import frappe
from frappe.utils import cint
from . import get_schema
from .graphql import execute
from .utils.depth_limit_validator import depth_limit_validator

from .utils.http import get_masked_variables, get_operation_name


@frappe.whitelist(allow_guest=True)
def execute_gql_query():
query, variables, operation_name = get_query()
output = execute(
query=query,
variables=variables,
operation_name=operation_name
validation_errors = validate(
schema=get_schema(),
document_ast=parse(query),
rules=(
depth_limit_validator(
max_depth=cint(frappe.local.conf.get("frappe_graphql_depth_limit")) or 10
),
)
)
if validation_errors:
output = frappe._dict(errors=validation_errors)
else:
output = execute(
query=query,
variables=variables,
operation_name=operation_name
)

frappe.clear_messages()
frappe.local.response = output
Expand Down
164 changes: 164 additions & 0 deletions frappe_graphql/utils/depth_limit_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
from frappe import _
from graphql import (ValidationRule, ValidationContext, DefinitionNode, FragmentDefinitionNode, OperationDefinitionNode,
Node, GraphQLError, FieldNode, InlineFragmentNode, FragmentSpreadNode)
from typing import Optional, Union, Callable, Pattern, List, Dict

IgnoreType = Union[Callable[[str], bool], Pattern, str]

"""
Copied from
https://github.com/graphql-python/graphene/blob/a61f0a214d4087acac097ab05f3969d77d0754b5/graphene/validation/depth_limit.py#L108
"""


def depth_limit_validator(
max_depth: int,
ignore: Optional[List[IgnoreType]] = None,
callback: Callable[[Dict[str, int]], None] = None,
):
class DepthLimitValidator(ValidationRule):
def __init__(self, validation_context: ValidationContext):
document = validation_context.document
definitions = document.definitions

fragments = get_fragments(definitions)
queries = get_queries_and_mutations(definitions)
query_depths = {}

for name in queries:
query_depths[name] = determine_depth(
node=queries[name],
fragments=fragments,
depth_so_far=0,
max_depth=max_depth,
context=validation_context,
operation_name=name,
ignore=ignore,
)
if callable(callback):
callback(query_depths)
super().__init__(validation_context)

return DepthLimitValidator


def get_fragments(
definitions: List[DefinitionNode],
) -> Dict[str, FragmentDefinitionNode]:
fragments = {}
for definition in definitions:
if isinstance(definition, FragmentDefinitionNode):
fragments[definition.name.value] = definition
return fragments


# This will actually get both queries and mutations.
# We can basically treat those the same
def get_queries_and_mutations(
definitions: List[DefinitionNode],
) -> Dict[str, OperationDefinitionNode]:
operations = {}

for definition in definitions:
if isinstance(definition, OperationDefinitionNode):
operation = definition.name.value if definition.name else "anonymous"
operations[operation] = definition
return operations


def determine_depth(
node: Node,
fragments: Dict[str, FragmentDefinitionNode],
depth_so_far: int,
max_depth: int,
context: ValidationContext,
operation_name: str,
ignore: Optional[List[IgnoreType]] = None,
) -> int:
if depth_so_far > max_depth:
context.report_error(
GraphQLError(
_("'{0}' exceeds maximum operation depth of {1}.").format(operation_name, max_depth),
[node],
)
)
return depth_so_far
if isinstance(node, FieldNode):
should_ignore = is_introspection_key(node.name.value) or is_ignored(
node, ignore
)

if should_ignore or not node.selection_set:
return 0
return 1 + max(
map(
lambda selection: determine_depth(
node=selection,
fragments=fragments,
depth_so_far=depth_so_far + 1,
max_depth=max_depth,
context=context,
operation_name=operation_name,
ignore=ignore,
),
node.selection_set.selections,
)
)
elif isinstance(node, FragmentSpreadNode):
return determine_depth(
node=fragments[node.name.value],
fragments=fragments,
depth_so_far=depth_so_far,
max_depth=max_depth,
context=context,
operation_name=operation_name,
ignore=ignore,
)
elif isinstance(
node, (InlineFragmentNode, FragmentDefinitionNode, OperationDefinitionNode)
):
return max(
map(
lambda selection: determine_depth(
node=selection,
fragments=fragments,
depth_so_far=depth_so_far,
max_depth=max_depth,
context=context,
operation_name=operation_name,
ignore=ignore,
),
node.selection_set.selections,
)
)
else:
raise Exception(
_("Depth crawler cannot handle: {0}.").format(node.kind)
)


def is_introspection_key(key):
# from: https://spec.graphql.org/June2018/#sec-Schema
# > All types and directives defined within a schema must not have a name which
# > begins with "__" (two underscores), as this is used exclusively
# > by GraphQL’s introspection system.
return str(key).startswith("__")


def is_ignored(node: FieldNode, ignore: Optional[List[IgnoreType]] = None) -> bool:
if ignore is None:
return False
for rule in ignore:
field_name = node.name.value
if isinstance(rule, str):
if field_name == rule:
return True
elif isinstance(rule, Pattern):
if rule.match(field_name):
return True
elif callable(rule):
if rule(field_name):
return True
else:
raise ValueError(_("Invalid ignore option: {0}.").format(rule))
return False

0 comments on commit 9e0c9e2

Please sign in to comment.