Skip to content

Commit 696fc6b

Browse files
author
Nicholas
authored
Initial depth check setup, testing has also been completed (canada-ca#141)
* Initial depth check setup, testing has also been completed * Added set depth during creation of the depth check object * Add Check-Depth function to all tests that use the schema for testing * Removed check for not query, as this will not check a mutation that contains a cycle * Implemented better depth check analysis * Typo in class definition * Fixed call to class * Added in cost analysis check, with very basic cost map * Created new cost calculating method, updated name, and applied to all tests * Added tests for costs, removed unused dicts for invalid tests * Removed unused imports * Cleaned up imports * Fixed imports
1 parent 958df2e commit 696fc6b

19 files changed

Lines changed: 661 additions & 119 deletions

api/app.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from flask import Flask
44
from flask_graphql import GraphQLView
55
from waitress import serve
6+
7+
from backend.security_check import SecurityAnalysisBackend
8+
69
from db import (
710
db,
811
DB_NAME,
@@ -15,19 +18,21 @@
1518

1619
app = Flask(__name__)
1720

18-
app.config[
19-
'SQLALCHEMY_DATABASE_URI'] = f'postgresql+psycopg2://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
21+
app.config['SQLALCHEMY_DATABASE_URI'] = f'postgresql+psycopg2://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
2022
app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN'] = True
2123
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
2224
app.debug = True
2325

2426
db.init_app(app)
2527

28+
backend = SecurityAnalysisBackend(max_depth=10, max_cost=1000)
29+
2630
app.add_url_rule(
2731
'/graphql',
2832
view_func=GraphQLView.as_view(
2933
'graphql',
3034
schema=schema,
35+
backend=backend,
3136
graphiql=True
3237
)
3338
)

api/backend/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from typing import (
2+
Dict,
3+
List
4+
)
5+
from graphql.language.ast import (
6+
FragmentDefinition,
7+
OperationDefinition
8+
)
9+
10+
11+
def get_fragments(definitions) -> Dict[str, FragmentDefinition]:
12+
return {
13+
definition.name.value: definition
14+
for definition in definitions
15+
if isinstance(definition, FragmentDefinition)
16+
}
17+
18+
19+
def get_queries_and_mutations(definitions) -> List[OperationDefinition]:
20+
return [
21+
definition
22+
for definition in definitions
23+
if isinstance(definition, OperationDefinition)
24+
]

api/backend/cost_check.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from typing import (
2+
Dict
3+
)
4+
from graphql.language.ast import (
5+
Document,
6+
FragmentDefinition,
7+
OperationDefinition,
8+
Node,
9+
FragmentSpread,
10+
Field,
11+
InlineFragment
12+
)
13+
from backend import (
14+
get_fragments,
15+
get_queries_and_mutations
16+
)
17+
18+
from backend.cost_map import cost_map
19+
20+
21+
class CostLimitReached(Exception):
22+
pass
23+
24+
25+
def measure_cost(node: Node, fragments: Dict[str, FragmentDefinition]) -> int:
26+
"""
27+
A function which recursively measures the cost of a Graphene Query
28+
:type node: Node
29+
:param node: Graphql-core object used for query traversal/indexing
30+
:type fragments: dict
31+
:param fragments: The fragments of the query
32+
:rtype: int
33+
:return: The cost of the node
34+
"""
35+
if isinstance(node, FragmentSpread):
36+
fragment = fragments.get(node.name.value)
37+
return measure_cost(node=fragment, fragments=fragments)
38+
39+
elif isinstance(node, Field):
40+
if node.name.value.lower() in ["__schema", "__introspection"]:
41+
return 0
42+
if not node.selection_set:
43+
return cost_map.get(node.name.value, 1)
44+
costs = []
45+
for selection in node.selection_set.selections:
46+
cost = measure_cost(node=selection, fragments=fragments)
47+
costs.append(cost)
48+
return sum(costs) + cost_map.get(node.name.value, 1)
49+
elif (
50+
isinstance(node, FragmentDefinition)
51+
or isinstance(node, OperationDefinition)
52+
or isinstance(node, InlineFragment)
53+
):
54+
costs = []
55+
for selection in node.selection_set.selections:
56+
cost = measure_cost(node=selection, fragments=fragments)
57+
costs.append(cost)
58+
return sum(costs)
59+
else:
60+
raise Exception("Unknown node")
61+
62+
63+
def check_cost_analysis(max_cost: int, document: Document):
64+
fragments = get_fragments(document.definitions)
65+
queries = get_queries_and_mutations(document.definitions)
66+
67+
for query in queries:
68+
total_cost = measure_cost(query, fragments)
69+
if total_cost > max_cost:
70+
raise CostLimitReached(
71+
'Query cost is too high'
72+
)

api/backend/cost_map.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
cost_map = {
2+
'getSectorById': 1,
3+
'getOrgById': 1,
4+
'getGroupById': 1
5+
}

api/backend/depth_check.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from typing import Dict
2+
from graphql.language.ast import (
3+
Document,
4+
FragmentDefinition,
5+
OperationDefinition,
6+
Node,
7+
FragmentSpread,
8+
Field,
9+
InlineFragment
10+
)
11+
12+
from backend import (
13+
get_fragments,
14+
get_queries_and_mutations
15+
)
16+
17+
18+
class DepthLimitReached(Exception):
19+
pass
20+
21+
22+
def measure_depth(node: Node, fragments: Dict[str, FragmentDefinition]) -> int:
23+
"""
24+
A function which recursively measures the depth of a Graphene Query
25+
:type node: Node
26+
:param node: Graphql-core object used for query traversal/indexing
27+
:type fragments: dict
28+
:param fragments: The fragments of the query
29+
:rtype: int
30+
:return: The max depth of the node
31+
"""
32+
if isinstance(node, FragmentSpread):
33+
fragment = fragments.get(node.name.value)
34+
return measure_depth(node=fragment, fragments=fragments)
35+
36+
elif isinstance(node, Field):
37+
if node.name.value.lower() in ["__schema", "__introspection"]:
38+
return 0
39+
if not node.selection_set:
40+
return 1
41+
depths = []
42+
for selection in node.selection_set.selections:
43+
depth = measure_depth(node=selection, fragments=fragments)
44+
depths.append(depth)
45+
return 1 + max(depths)
46+
elif (
47+
isinstance(node, FragmentDefinition)
48+
or isinstance(node, OperationDefinition)
49+
or isinstance(node, InlineFragment)
50+
):
51+
depths = []
52+
for selection in node.selection_set.selections:
53+
depth = measure_depth(node=selection, fragments=fragments)
54+
depths.append(depth)
55+
return max(depths)
56+
else:
57+
raise Exception("Unknown node")
58+
59+
60+
def check_max_depth(max_depth: int, document: Document):
61+
fragments = get_fragments(document.definitions)
62+
queries = get_queries_and_mutations(document.definitions)
63+
64+
for query in queries:
65+
depth = measure_depth(query, fragments)
66+
if depth > max_depth:
67+
raise DepthLimitReached(
68+
'Query is too complex'
69+
)

api/backend/security_check.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from typing import (
2+
Union,
3+
Optional,
4+
Any
5+
)
6+
from graphql import GraphQLDocument, GraphQLSchema
7+
from graphql.backend.core import GraphQLCoreBackend
8+
from graphql.language.ast import Document
9+
10+
from backend.depth_check import check_max_depth
11+
from backend.cost_check import check_cost_analysis
12+
13+
14+
class SecurityAnalysisBackend(GraphQLCoreBackend):
15+
def __init__(self, max_depth=10, max_cost=1000, executor: Optional[Any] = None):
16+
super().__init__(executor=executor)
17+
self.max_depth = max_depth
18+
self.max_cost = max_cost
19+
20+
def document_from_string(self, schema: GraphQLSchema, document_string: Union[Document, str]) -> GraphQLDocument:
21+
document = super().document_from_string(schema, document_string)
22+
check_max_depth(max_depth=self.max_depth, document=document.document_ast)
23+
check_cost_analysis(max_cost=self.max_cost, document=document.document_ast)
24+
return document

api/resolvers/sectors.py

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,41 @@
11
from graphql import GraphQLError
22
from schemas.sectors import Sectors, SectorsModel
33
from model_enums.sectors import SectorEnums
4+
from manage import app
45

56

67
# Resolvers
78
def resolve_get_sector_by_id(self, info, **kwargs):
8-
"""Return a sector by its row ID"""
9-
sector_id = kwargs.get('id', 1)
10-
query = Sectors.get_query(info).filter(
11-
SectorsModel.id == sector_id
12-
)
13-
if not len(query.all()):
14-
raise GraphQLError("Error, Invalid ID")
15-
return query.all()
9+
"""Return a sector by its row ID"""
10+
sector_id = kwargs.get('id', 1)
11+
with app.app_context():
12+
query = Sectors.get_query(info).filter(
13+
SectorsModel.id == sector_id
14+
)
15+
if not len(query.all()):
16+
raise GraphQLError("Error, Invalid ID")
17+
return query.all()
1618

1719

1820
def resolve_get_sectors_by_sector(self, info, **kwargs):
19-
"""Return a list of sectors by its sector"""
20-
sector = kwargs.get('sector', 'EMPTY')
21-
query = Sectors.get_query(info).filter(
22-
SectorsModel.sector == sector
23-
)
24-
25-
if not len(query.all()):
26-
raise GraphQLError("Error, Sector does not exist")
27-
return query.all()
21+
"""Return a list of sectors by its sector"""
22+
sector = kwargs.get('sector', 'EMPTY')
23+
with app.app_context():
24+
query = Sectors.get_query(info).filter(
25+
SectorsModel.sector == sector
26+
)
27+
if not len(query.all()):
28+
raise GraphQLError("Error, Sector does not exist")
29+
return query.all()
2830

2931

3032
def resolve_get_sector_by_zone(self, info, **kwargs):
31-
"""Return a list of sectors by their zone"""
32-
zone = kwargs.get('zone')
33-
query = Sectors.get_query(info).filter(
34-
SectorsModel.zone == zone
35-
)
36-
if not len(query.all()):
37-
raise GraphQLError("Error, Zone does not exist")
38-
return query.all()
33+
"""Return a list of sectors by their zone"""
34+
zone = kwargs.get('zone')
35+
with app.app_context():
36+
query = Sectors.get_query(info).filter(
37+
SectorsModel.zone == zone
38+
)
39+
if not len(query.all()):
40+
raise GraphQLError("Error, Zone does not exist")
41+
return query.all()

0 commit comments

Comments
 (0)