Skip to content

Commit 9b458b5

Browse files
committed
Python: ORM: Add flow to collection/dict queries
1 parent 9cff4cb commit 9b458b5

File tree

4 files changed

+113
-7
lines changed

4 files changed

+113
-7
lines changed

python/ql/lib/semmle/python/frameworks/Django.qll

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ module PrivateDjango {
614614
override predicate isDbFetch() { none() }
615615
}
616616

617-
/** A method on a query-set or manager that returns an instance of a django model. */
617+
/** A method call on a query-set or manager that returns an instance of a django model. */
618618
private class QuerySetMethod extends InstanceSource, DataFlow::CallCfgNode {
619619
API::Node modelClass;
620620
string methodName;
@@ -630,6 +630,76 @@ module PrivateDjango {
630630
override predicate isDbFetch() { not methodName = "create" }
631631
}
632632

633+
/**
634+
* A method call on a query-set or manager that returns a collection
635+
* containing instances of a django models.
636+
*/
637+
class QuerySetMethodInstanceCollection extends DataFlow::CallCfgNode {
638+
API::Node modelClass;
639+
string methodName;
640+
641+
QuerySetMethodInstanceCollection() {
642+
modelClass = subclassRef() and
643+
this = querySetReturningMethod(modelClass, methodName).getACall() and
644+
not methodName in ["none", "datetimes", "dates", "values", "values_list"]
645+
or
646+
// TODO: When we have flow-summaries, we should be able to model `values` and `values_list`
647+
// Potentially by doing `synthetic ===store of list element==> <Model>.objects`, and then
648+
// `.all()` just keeps that content, and `.first()` will do a read step (of the list element).
649+
//
650+
// So for `Model.objects.filter().exclude().first()` we would have
651+
// 1: <Synthetic node for Model> ===store of list element==> Model.objects
652+
// 2: Model.objects ==> Model.objects.filter()
653+
// 3: Model.objects.filter() ==> Model.objects.filter().exclude()
654+
// 4: Model.objects.filter().exclude() ===read of list element==> Model.objects.filter().exclude().first()
655+
//
656+
// This also means that `.none()` could clear contents. Right now we
657+
// think that the result of `Model.objects.none().all()` can contain
658+
// Model objects, but it will be empty due to the `.none()` part. Not
659+
// that this is important, since no-one would need to write
660+
// `.none().all()` code anyway, but it would be cool to be able to model it properly :D
661+
//
662+
//
663+
// The big benefit is for how we could then model `values`/`values_list`. For example,
664+
// `Model.objects.value_list(name, description)` would result in (for the attribute description)
665+
// 0: <Synthetic node for Model> -- [attr description]
666+
// 1: ==> Model.objects [ListElement, attr description]
667+
// 2: ==> .value_list(...) [ListElement, TupleIndex 1]
668+
//
669+
// but for now, we just model a store step directly from the synthetic
670+
// node to the method call.
671+
// extra method on query-set/manager that does _not_ return a query-set
672+
modelClass = subclassRef() and
673+
methodName in ["iterator", "bulk_create"] and
674+
this = [manager(modelClass), querySet(modelClass)].getMember(methodName).getACall()
675+
}
676+
677+
/** Gets the model class that this is an instance source of. */
678+
API::Node getModelClass() { result = modelClass }
679+
680+
/** Holds if this instance-source is fetching data from the DB. */
681+
predicate isDbFetch() { not methodName = "bulk_create" }
682+
}
683+
684+
/**
685+
* A method call on a query-set or manager that returns a dictionary
686+
* containing instances of a django models as the values.
687+
*/
688+
class QuerySetMethodInstanceDictValue extends DataFlow::CallCfgNode {
689+
API::Node modelClass;
690+
691+
QuerySetMethodInstanceDictValue() {
692+
modelClass = subclassRef() and
693+
this = [manager(modelClass), querySet(modelClass)].getMember("in_bulk").getACall()
694+
}
695+
696+
/** Gets the model class that this is an instance source of. */
697+
API::Node getModelClass() { result = modelClass }
698+
699+
/** Holds if this instance-source is fetching data from the DB. */
700+
predicate isDbFetch() { any() }
701+
}
702+
633703
/**
634704
* Gets a reference to an instance of `django.db.models.Model` class or any subclass,
635705
* where `modelClass` specifies the class.
@@ -765,6 +835,22 @@ module PrivateDjango {
765835
c.(DataFlow::AttributeContent).getAttribute() = fieldName and
766836
nodeTo = call
767837
)
838+
or
839+
// synthetic -> method-call that returns collection of ORM models (all/filter/...)
840+
exists(API::Node modelClass |
841+
nodeFrom.(SyntheticDjangoOrmModelNode).getModelClass() = modelClass and
842+
nodeTo.(Model::QuerySetMethodInstanceCollection).getModelClass() = modelClass and
843+
nodeTo.(Model::QuerySetMethodInstanceCollection).isDbFetch() and
844+
c instanceof DataFlow::ListElementContent
845+
)
846+
or
847+
// synthetic -> method-call that returns dictionary with ORM models as values
848+
exists(API::Node modelClass |
849+
nodeFrom.(SyntheticDjangoOrmModelNode).getModelClass() = modelClass and
850+
nodeTo.(Model::QuerySetMethodInstanceDictValue).getModelClass() = modelClass and
851+
nodeTo.(Model::QuerySetMethodInstanceDictValue).isDbFetch() and
852+
c instanceof DataFlow::DictionaryElementAnyContent
853+
)
768854
}
769855

770856
override predicate jumpStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {

python/ql/test/library-tests/frameworks/django-orm/ReflectedXss.expected

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
edges
2+
| testapp/orm_security_tests.py:15:1:15:27 | [orm-model] Class Person [Attribute age] | testapp/orm_security_tests.py:42:23:42:42 | ControlFlowNode for Attribute() [List element, Attribute age] |
23
| testapp/orm_security_tests.py:15:1:15:27 | [orm-model] Class Person [Attribute age] | testapp/orm_security_tests.py:51:14:51:53 | ControlFlowNode for Attribute() [Attribute age] |
4+
| testapp/orm_security_tests.py:15:1:15:27 | [orm-model] Class Person [Attribute name] | testapp/orm_security_tests.py:42:23:42:42 | ControlFlowNode for Attribute() [List element, Attribute name] |
35
| testapp/orm_security_tests.py:15:1:15:27 | [orm-model] Class Person [Attribute name] | testapp/orm_security_tests.py:47:14:47:53 | ControlFlowNode for Attribute() [Attribute name] |
46
| testapp/orm_security_tests.py:19:12:19:18 | ControlFlowNode for request | testapp/orm_security_tests.py:22:23:22:34 | ControlFlowNode for Attribute |
57
| testapp/orm_security_tests.py:19:12:19:18 | ControlFlowNode for request | testapp/orm_security_tests.py:23:22:23:33 | ControlFlowNode for Attribute |
@@ -12,6 +14,14 @@ edges
1214
| testapp/orm_security_tests.py:23:22:23:40 | ControlFlowNode for Subscript | testapp/orm_security_tests.py:23:9:23:14 | [post store] ControlFlowNode for person [Attribute age] |
1315
| testapp/orm_security_tests.py:28:9:28:14 | ControlFlowNode for person [Attribute age] | testapp/orm_security_tests.py:15:1:15:27 | [orm-model] Class Person [Attribute age] |
1416
| testapp/orm_security_tests.py:28:9:28:14 | ControlFlowNode for person [Attribute name] | testapp/orm_security_tests.py:15:1:15:27 | [orm-model] Class Person [Attribute name] |
17+
| testapp/orm_security_tests.py:42:13:42:18 | SSA variable person [Attribute age] | testapp/orm_security_tests.py:43:62:43:67 | ControlFlowNode for person [Attribute age] |
18+
| testapp/orm_security_tests.py:42:13:42:18 | SSA variable person [Attribute name] | testapp/orm_security_tests.py:43:49:43:54 | ControlFlowNode for person [Attribute name] |
19+
| testapp/orm_security_tests.py:42:23:42:42 | ControlFlowNode for Attribute() [List element, Attribute age] | testapp/orm_security_tests.py:42:13:42:18 | SSA variable person [Attribute age] |
20+
| testapp/orm_security_tests.py:42:23:42:42 | ControlFlowNode for Attribute() [List element, Attribute name] | testapp/orm_security_tests.py:42:13:42:18 | SSA variable person [Attribute name] |
21+
| testapp/orm_security_tests.py:43:49:43:54 | ControlFlowNode for person [Attribute name] | testapp/orm_security_tests.py:43:49:43:59 | ControlFlowNode for Attribute |
22+
| testapp/orm_security_tests.py:43:49:43:59 | ControlFlowNode for Attribute | testapp/orm_security_tests.py:44:29:44:37 | ControlFlowNode for resp_text |
23+
| testapp/orm_security_tests.py:43:62:43:67 | ControlFlowNode for person [Attribute age] | testapp/orm_security_tests.py:43:62:43:71 | ControlFlowNode for Attribute |
24+
| testapp/orm_security_tests.py:43:62:43:71 | ControlFlowNode for Attribute | testapp/orm_security_tests.py:44:29:44:37 | ControlFlowNode for resp_text |
1525
| testapp/orm_security_tests.py:47:14:47:53 | ControlFlowNode for Attribute() [Attribute name] | testapp/orm_security_tests.py:48:46:48:51 | ControlFlowNode for person [Attribute name] |
1626
| testapp/orm_security_tests.py:48:46:48:51 | ControlFlowNode for person [Attribute name] | testapp/orm_security_tests.py:48:46:48:56 | ControlFlowNode for Attribute |
1727
| testapp/orm_security_tests.py:48:46:48:56 | ControlFlowNode for Attribute | testapp/orm_security_tests.py:48:25:48:57 | ControlFlowNode for Attribute() |
@@ -47,6 +57,15 @@ nodes
4757
| testapp/orm_security_tests.py:23:22:23:40 | ControlFlowNode for Subscript | semmle.label | ControlFlowNode for Subscript |
4858
| testapp/orm_security_tests.py:28:9:28:14 | ControlFlowNode for person [Attribute age] | semmle.label | ControlFlowNode for person [Attribute age] |
4959
| testapp/orm_security_tests.py:28:9:28:14 | ControlFlowNode for person [Attribute name] | semmle.label | ControlFlowNode for person [Attribute name] |
60+
| testapp/orm_security_tests.py:42:13:42:18 | SSA variable person [Attribute age] | semmle.label | SSA variable person [Attribute age] |
61+
| testapp/orm_security_tests.py:42:13:42:18 | SSA variable person [Attribute name] | semmle.label | SSA variable person [Attribute name] |
62+
| testapp/orm_security_tests.py:42:23:42:42 | ControlFlowNode for Attribute() [List element, Attribute age] | semmle.label | ControlFlowNode for Attribute() [List element, Attribute age] |
63+
| testapp/orm_security_tests.py:42:23:42:42 | ControlFlowNode for Attribute() [List element, Attribute name] | semmle.label | ControlFlowNode for Attribute() [List element, Attribute name] |
64+
| testapp/orm_security_tests.py:43:49:43:54 | ControlFlowNode for person [Attribute name] | semmle.label | ControlFlowNode for person [Attribute name] |
65+
| testapp/orm_security_tests.py:43:49:43:59 | ControlFlowNode for Attribute | semmle.label | ControlFlowNode for Attribute |
66+
| testapp/orm_security_tests.py:43:62:43:67 | ControlFlowNode for person [Attribute age] | semmle.label | ControlFlowNode for person [Attribute age] |
67+
| testapp/orm_security_tests.py:43:62:43:71 | ControlFlowNode for Attribute | semmle.label | ControlFlowNode for Attribute |
68+
| testapp/orm_security_tests.py:44:29:44:37 | ControlFlowNode for resp_text | semmle.label | ControlFlowNode for resp_text |
5069
| testapp/orm_security_tests.py:47:14:47:53 | ControlFlowNode for Attribute() [Attribute name] | semmle.label | ControlFlowNode for Attribute() [Attribute name] |
5170
| testapp/orm_security_tests.py:48:25:48:57 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() |
5271
| testapp/orm_security_tests.py:48:46:48:51 | ControlFlowNode for person [Attribute name] | semmle.label | ControlFlowNode for person [Attribute name] |
@@ -75,6 +94,7 @@ nodes
7594
| testapp/orm_security_tests.py:121:25:121:36 | ControlFlowNode for Attribute | semmle.label | ControlFlowNode for Attribute |
7695
subpaths
7796
#select
97+
| testapp/orm_security_tests.py:44:29:44:37 | ControlFlowNode for resp_text | testapp/orm_security_tests.py:19:12:19:18 | ControlFlowNode for request | testapp/orm_security_tests.py:44:29:44:37 | ControlFlowNode for resp_text | Cross-site scripting vulnerability due to $@. | testapp/orm_security_tests.py:19:12:19:18 | ControlFlowNode for request | a user-provided value |
7898
| testapp/orm_security_tests.py:48:25:48:57 | ControlFlowNode for Attribute() | testapp/orm_security_tests.py:19:12:19:18 | ControlFlowNode for request | testapp/orm_security_tests.py:48:25:48:57 | ControlFlowNode for Attribute() | Cross-site scripting vulnerability due to $@. | testapp/orm_security_tests.py:19:12:19:18 | ControlFlowNode for request | a user-provided value |
7999
| testapp/orm_security_tests.py:55:25:55:55 | ControlFlowNode for Attribute() | testapp/orm_security_tests.py:19:12:19:18 | ControlFlowNode for request | testapp/orm_security_tests.py:55:25:55:55 | ControlFlowNode for Attribute() | Cross-site scripting vulnerability due to $@. | testapp/orm_security_tests.py:19:12:19:18 | ControlFlowNode for request | a user-provided value |
80100
| testapp/orm_security_tests.py:102:25:102:36 | ControlFlowNode for Attribute | testapp/orm_security_tests.py:95:37:95:43 | ControlFlowNode for request | testapp/orm_security_tests.py:102:25:102:36 | ControlFlowNode for Attribute | Cross-site scripting vulnerability due to $@. | testapp/orm_security_tests.py:95:37:95:43 | ControlFlowNode for request | a user-provided value |

python/ql/test/library-tests/frameworks/django-orm/testapp/orm_security_tests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def person(request):
4141
resp_text = "<h1>Persons:</h1>"
4242
for person in Person.objects.all():
4343
resp_text += "\n{} (age {})".format(person.name, person.age)
44-
return HttpResponse(resp_text) # NOT OK
44+
return HttpResponse(resp_text) # NOT OK
4545

4646
def show_name(request):
4747
person = Person.objects.get(id=request.GET["id"])

python/ql/test/library-tests/frameworks/django-orm/testapp/orm_tests.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,8 @@ def test_load_single():
307307
def test_load_many():
308308
objs = TestLoad.objects.all()
309309
for obj in objs:
310-
SINK(obj.text) # $ MISSING: flow
311-
SINK(objs[0].text) # $ MISSING: flow
310+
SINK(obj.text) # $ flow="SOURCE, l:-10 -> obj.text"
311+
SINK(objs[0].text) # $ flow="SOURCE, l:-11 -> objs[0].text"
312312

313313
def test_load_many_skip():
314314
objs = TestLoad.objects.all()[5:]
@@ -323,8 +323,8 @@ def test_load_qs_chain_single():
323323
def test_load_qs_chain_many():
324324
objs = TestLoad.objects.all().filter(text__contains="s").exclude(text=None)
325325
for obj in objs:
326-
SINK(obj.text) # $ MISSING: flow
327-
SINK(objs[0].text) # $ MISSING: flow
326+
SINK(obj.text) # $ flow="SOURCE, l:-26 -> obj.text"
327+
SINK(objs[0].text) # $ flow="SOURCE, l:-27 -> objs[0].text"
328328

329329
def test_load_values():
330330
# see https://docs.djangoproject.com/en/4.0/ref/models/querysets/#django.db.models.query.QuerySet.values
@@ -363,7 +363,7 @@ def test_load_in_bulk():
363363
d = TestLoad.objects.in_bulk([1])
364364
for val in d.values():
365365
SINK(val.text) # $ MISSING: flow
366-
SINK(d[1].text) # $ MISSING: flow
366+
SINK(d[1].text) # $ flow="SOURCE, l:-66 -> d[1].text"
367367

368368

369369
# Good resources:

0 commit comments

Comments
 (0)