Skip to content

Commit 9e0c9e2

Browse files
authored
Restrict Depth Limit (#55)
* refactor: restrict depth limit of queries and mutations * refactor: 10 limit instead of 20 * refactor: add docs for depth limit
1 parent 8a4b42e commit 9e0c9e2

File tree

3 files changed

+194
-6
lines changed

3 files changed

+194
-6
lines changed

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ Result
114114
</details>
115115
<hr/>
116116

117+
## Restrict Query/Mutation depth
118+
119+
Query/Mutation is restricted by default to 10.
120+
121+
You can change the depth limit by setting the site config `frappe_graphql_depth_limit: 15`.
122+
123+
<hr/>
124+
117125
## Subscriptions
118126
Get notified instantly of the updates via existing frappe's SocketIO. Please read more on the implementation details [here](./docs/subscriptions.md)
119127
<hr/>
@@ -223,7 +231,8 @@ def is_introspection_disabled():
223231
return not cint(frappe.local.conf.get("developer_mode")) and \
224232
not cint(frappe.local.conf.get("enable_introspection_in_production"))
225233
```
226-
<hr>
234+
<hr/>
235+
227236
## Introspection in Production
228237
Introspection is disabled by default in production mode. You can enable by setting the site config `enable_introspection_in_production: 1`.
229238

frappe_graphql/api.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
1-
from graphql import GraphQLError
1+
from graphql import GraphQLError, validate, parse
22
from typing import List
33

44
import frappe
5+
from frappe.utils import cint
6+
from . import get_schema
57
from .graphql import execute
8+
from .utils.depth_limit_validator import depth_limit_validator
69

710
from .utils.http import get_masked_variables, get_operation_name
811

912

1013
@frappe.whitelist(allow_guest=True)
1114
def execute_gql_query():
1215
query, variables, operation_name = get_query()
13-
output = execute(
14-
query=query,
15-
variables=variables,
16-
operation_name=operation_name
16+
validation_errors = validate(
17+
schema=get_schema(),
18+
document_ast=parse(query),
19+
rules=(
20+
depth_limit_validator(
21+
max_depth=cint(frappe.local.conf.get("frappe_graphql_depth_limit")) or 10
22+
),
23+
)
1724
)
25+
if validation_errors:
26+
output = frappe._dict(errors=validation_errors)
27+
else:
28+
output = execute(
29+
query=query,
30+
variables=variables,
31+
operation_name=operation_name
32+
)
1833

1934
frappe.clear_messages()
2035
frappe.local.response = output
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
from frappe import _
2+
from graphql import (ValidationRule, ValidationContext, DefinitionNode, FragmentDefinitionNode, OperationDefinitionNode,
3+
Node, GraphQLError, FieldNode, InlineFragmentNode, FragmentSpreadNode)
4+
from typing import Optional, Union, Callable, Pattern, List, Dict
5+
6+
IgnoreType = Union[Callable[[str], bool], Pattern, str]
7+
8+
"""
9+
Copied from
10+
https://github.com/graphql-python/graphene/blob/a61f0a214d4087acac097ab05f3969d77d0754b5/graphene/validation/depth_limit.py#L108
11+
"""
12+
13+
14+
def depth_limit_validator(
15+
max_depth: int,
16+
ignore: Optional[List[IgnoreType]] = None,
17+
callback: Callable[[Dict[str, int]], None] = None,
18+
):
19+
class DepthLimitValidator(ValidationRule):
20+
def __init__(self, validation_context: ValidationContext):
21+
document = validation_context.document
22+
definitions = document.definitions
23+
24+
fragments = get_fragments(definitions)
25+
queries = get_queries_and_mutations(definitions)
26+
query_depths = {}
27+
28+
for name in queries:
29+
query_depths[name] = determine_depth(
30+
node=queries[name],
31+
fragments=fragments,
32+
depth_so_far=0,
33+
max_depth=max_depth,
34+
context=validation_context,
35+
operation_name=name,
36+
ignore=ignore,
37+
)
38+
if callable(callback):
39+
callback(query_depths)
40+
super().__init__(validation_context)
41+
42+
return DepthLimitValidator
43+
44+
45+
def get_fragments(
46+
definitions: List[DefinitionNode],
47+
) -> Dict[str, FragmentDefinitionNode]:
48+
fragments = {}
49+
for definition in definitions:
50+
if isinstance(definition, FragmentDefinitionNode):
51+
fragments[definition.name.value] = definition
52+
return fragments
53+
54+
55+
# This will actually get both queries and mutations.
56+
# We can basically treat those the same
57+
def get_queries_and_mutations(
58+
definitions: List[DefinitionNode],
59+
) -> Dict[str, OperationDefinitionNode]:
60+
operations = {}
61+
62+
for definition in definitions:
63+
if isinstance(definition, OperationDefinitionNode):
64+
operation = definition.name.value if definition.name else "anonymous"
65+
operations[operation] = definition
66+
return operations
67+
68+
69+
def determine_depth(
70+
node: Node,
71+
fragments: Dict[str, FragmentDefinitionNode],
72+
depth_so_far: int,
73+
max_depth: int,
74+
context: ValidationContext,
75+
operation_name: str,
76+
ignore: Optional[List[IgnoreType]] = None,
77+
) -> int:
78+
if depth_so_far > max_depth:
79+
context.report_error(
80+
GraphQLError(
81+
_("'{0}' exceeds maximum operation depth of {1}.").format(operation_name, max_depth),
82+
[node],
83+
)
84+
)
85+
return depth_so_far
86+
if isinstance(node, FieldNode):
87+
should_ignore = is_introspection_key(node.name.value) or is_ignored(
88+
node, ignore
89+
)
90+
91+
if should_ignore or not node.selection_set:
92+
return 0
93+
return 1 + max(
94+
map(
95+
lambda selection: determine_depth(
96+
node=selection,
97+
fragments=fragments,
98+
depth_so_far=depth_so_far + 1,
99+
max_depth=max_depth,
100+
context=context,
101+
operation_name=operation_name,
102+
ignore=ignore,
103+
),
104+
node.selection_set.selections,
105+
)
106+
)
107+
elif isinstance(node, FragmentSpreadNode):
108+
return determine_depth(
109+
node=fragments[node.name.value],
110+
fragments=fragments,
111+
depth_so_far=depth_so_far,
112+
max_depth=max_depth,
113+
context=context,
114+
operation_name=operation_name,
115+
ignore=ignore,
116+
)
117+
elif isinstance(
118+
node, (InlineFragmentNode, FragmentDefinitionNode, OperationDefinitionNode)
119+
):
120+
return max(
121+
map(
122+
lambda selection: determine_depth(
123+
node=selection,
124+
fragments=fragments,
125+
depth_so_far=depth_so_far,
126+
max_depth=max_depth,
127+
context=context,
128+
operation_name=operation_name,
129+
ignore=ignore,
130+
),
131+
node.selection_set.selections,
132+
)
133+
)
134+
else:
135+
raise Exception(
136+
_("Depth crawler cannot handle: {0}.").format(node.kind)
137+
)
138+
139+
140+
def is_introspection_key(key):
141+
# from: https://spec.graphql.org/June2018/#sec-Schema
142+
# > All types and directives defined within a schema must not have a name which
143+
# > begins with "__" (two underscores), as this is used exclusively
144+
# > by GraphQL’s introspection system.
145+
return str(key).startswith("__")
146+
147+
148+
def is_ignored(node: FieldNode, ignore: Optional[List[IgnoreType]] = None) -> bool:
149+
if ignore is None:
150+
return False
151+
for rule in ignore:
152+
field_name = node.name.value
153+
if isinstance(rule, str):
154+
if field_name == rule:
155+
return True
156+
elif isinstance(rule, Pattern):
157+
if rule.match(field_name):
158+
return True
159+
elif callable(rule):
160+
if rule(field_name):
161+
return True
162+
else:
163+
raise ValueError(_("Invalid ignore option: {0}.").format(rule))
164+
return False

0 commit comments

Comments
 (0)