Skip to content

Commit eecc3ca

Browse files
authored
Merge pull request github#3503 from RasmusWL/python-fix-django-taint-sinks
Python: Fix django taint sinks
2 parents 97128b1 + 67be45f commit eecc3ca

File tree

10 files changed

+252
-64
lines changed

10 files changed

+252
-64
lines changed

python/ql/src/Security/CWE-079/ReflectedXss.ql

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ class ReflectedXssConfiguration extends TaintTracking::Configuration {
2828
source instanceof HttpRequestTaintSource
2929
}
3030

31-
override predicate isSink(TaintTracking::Sink sink) { sink instanceof HttpResponseTaintSink }
31+
override predicate isSink(TaintTracking::Sink sink) {
32+
sink instanceof HttpResponseTaintSink and
33+
not sink instanceof DjangoResponseContent
34+
or
35+
sink instanceof DjangoResponseContentXSSVulnerable
36+
}
3237
}
3338

3439
from ReflectedXssConfiguration config, TaintedPathSource src, TaintedPathSink sink

python/ql/src/semmle/python/web/django/Redirect.qll

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,30 @@ private import semmle.python.web.django.Shared
1111
private import semmle.python.web.Http
1212

1313
/**
14-
* Represents an argument to the `django.redirect` function.
14+
* The URL argument for a call to the `django.shortcuts.redirect` function.
1515
*/
16-
class DjangoRedirect extends HttpRedirectTaintSink {
17-
override string toString() { result = "django.redirect" }
16+
class DjangoShortcutsRedirectSink extends HttpRedirectTaintSink {
17+
override string toString() { result = "DjangoShortcutsRedirectSink" }
1818

19-
DjangoRedirect() { this = redirect().getACall().getAnArg() }
19+
DjangoShortcutsRedirectSink() {
20+
this = Value::named("django.shortcuts.redirect").(FunctionValue).getArgumentForCall(_, 0)
21+
}
22+
}
23+
24+
/** DEPRECATED: Use `DjangoShortcutsRedirectSink` instead. */
25+
deprecated class DjangoRedirect = DjangoShortcutsRedirectSink;
26+
27+
/**
28+
* The URL argument when instantiating a Django Redirect Response.
29+
*/
30+
class DjangoRedirectResponseSink extends HttpRedirectTaintSink {
31+
DjangoRedirectResponseSink() {
32+
exists(CallNode call | call = any(DjangoRedirectResponseClass cls).getACall() |
33+
this = call.getArg(0)
34+
or
35+
this = call.getArgByName("redirect_to")
36+
)
37+
}
38+
39+
override string toString() { result = "DjangoRedirectResponseSink" }
2040
}

python/ql/src/semmle/python/web/django/Response.qll

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,25 @@ private import semmle.python.web.django.Shared
55
private import semmle.python.web.Http
66

77
/**
8+
* DEPRECATED: This class is internal to the django library modeling, and should
9+
* never be used by anyone.
10+
*
811
* A django.http.response.Response object
912
* This isn't really a "taint", but we use the value tracking machinery to
1013
* track the flow of response objects.
1114
*/
12-
class DjangoResponse extends TaintKind {
13-
DjangoResponse() { this = "django.response.HttpResponse" }
14-
}
15+
deprecated class DjangoResponse = DjangoResponseKind;
1516

16-
private ClassValue theDjangoHttpResponseClass() {
17-
(
18-
// version 1.x
19-
result = Value::named("django.http.response.HttpResponse")
20-
or
21-
// version 2.x
22-
// https://docs.djangoproject.com/en/2.2/ref/request-response/#httpresponse-objects
23-
result = Value::named("django.http.HttpResponse")
24-
) and
25-
// TODO: does this do anything? when could they be the same???
26-
not result = theDjangoHttpRedirectClass()
17+
/** INTERNAL class used for tracking a django response object. */
18+
private class DjangoResponseKind extends TaintKind {
19+
DjangoResponseKind() { this = "django.response.HttpResponse" }
2720
}
2821

29-
/** internal class used for tracking a django response. */
22+
/** INTERNAL taint-source used for tracking a django response object. */
3023
private class DjangoResponseSource extends TaintSource {
31-
DjangoResponseSource() {
32-
exists(ClassValue cls |
33-
cls.getASuperType() = theDjangoHttpResponseClass() and
34-
cls.getACall() = this
35-
)
36-
}
24+
DjangoResponseSource() { exists(DjangoContentResponseClass cls | cls.getACall() = this) }
3725

38-
override predicate isSourceOf(TaintKind kind) { kind instanceof DjangoResponse }
26+
override predicate isSourceOf(TaintKind kind) { kind instanceof DjangoResponseKind }
3927

4028
override string toString() { result = "django.http.response.HttpResponse" }
4129
}
@@ -45,7 +33,7 @@ class DjangoResponseWrite extends HttpResponseTaintSink {
4533
DjangoResponseWrite() {
4634
exists(AttrNode meth, CallNode call |
4735
call.getFunction() = meth and
48-
any(DjangoResponse response).taints(meth.getObject("write")) and
36+
any(DjangoResponseKind response).taints(meth.getObject("write")) and
4937
this = call.getArg(0)
5038
)
5139
}
@@ -55,27 +43,42 @@ class DjangoResponseWrite extends HttpResponseTaintSink {
5543
override string toString() { result = "django.Response.write(...)" }
5644
}
5745

58-
/** An argument to initialization of a django response, which is vulnerable to external data (xss) */
46+
/**
47+
* An argument to initialization of a django response.
48+
*/
5949
class DjangoResponseContent extends HttpResponseTaintSink {
50+
DjangoContentResponseClass cls;
51+
CallNode call;
52+
6053
DjangoResponseContent() {
61-
exists(CallNode call, ClassValue cls |
62-
cls.getASuperType() = theDjangoHttpResponseClass() and
63-
call.getFunction().pointsTo(cls)
64-
|
65-
call.getArg(0) = this
66-
or
67-
call.getArgByName("content") = this
68-
)
54+
call = cls.getACall() and
55+
this = cls.getContentArg(call)
6956
}
7057

7158
override predicate sinks(TaintKind kind) { kind instanceof StringKind }
7259

7360
override string toString() { result = "django.Response(...)" }
7461
}
7562

63+
/**
64+
* An argument to initialization of a django response, which is vulnerable to external data (XSS).
65+
*/
66+
class DjangoResponseContentXSSVulnerable extends DjangoResponseContent {
67+
override DjangoXSSVulnerableResponseClass cls;
68+
69+
DjangoResponseContentXSSVulnerable() {
70+
not exists(cls.getContentTypeArg(call))
71+
or
72+
exists(StringValue s |
73+
cls.getContentTypeArg(call).pointsTo(s) and
74+
s.getText().matches("text/html%")
75+
)
76+
}
77+
}
78+
7679
class DjangoCookieSet extends CookieSet, CallNode {
7780
DjangoCookieSet() {
78-
any(DjangoResponse r).taints(this.getFunction().(AttrNode).getObject("set_cookie"))
81+
any(DjangoResponseKind r).taints(this.getFunction().(AttrNode).getObject("set_cookie"))
7982
}
8083

8184
override string toString() { result = CallNode.super.toString() }
Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,84 @@
11
import python
22

3-
/** django.shortcuts.redirect */
4-
FunctionValue redirect() { result = Value::named("django.shortcuts.redirect") }
3+
/** DEPRECATED: Use `Value::named("django.shortcuts.redirect")` instead. */
4+
deprecated FunctionValue redirect() { result = Value::named("django.shortcuts.redirect") }
55

6-
ClassValue theDjangoHttpRedirectClass() {
6+
/** DEPRECATED: Use `DjangoRedirectResponseClass` instead. */
7+
deprecated ClassValue theDjangoHttpRedirectClass() {
78
// version 1.x
89
result = Value::named("django.http.response.HttpResponseRedirectBase")
910
or
1011
// version 2.x
1112
result = Value::named("django.http.HttpResponseRedirectBase")
1213
}
14+
15+
/** A class that is a Django Redirect Response (subclass of `django.http.HttpResponseRedirectBase`). */
16+
class DjangoRedirectResponseClass extends ClassValue {
17+
DjangoRedirectResponseClass() {
18+
exists(ClassValue redirect_base |
19+
// version 1.x
20+
redirect_base = Value::named("django.http.response.HttpResponseRedirectBase")
21+
or
22+
// version 2.x and 3.x
23+
redirect_base = Value::named("django.http.HttpResponseRedirectBase")
24+
|
25+
this.getASuperType() = redirect_base
26+
)
27+
}
28+
}
29+
30+
/**
31+
* A class that is a Django Response, which can contain content.
32+
* A subclass of `django.http.HttpResponse` that is not a `DjangoRedirectResponseClass`.
33+
*/
34+
class DjangoContentResponseClass extends ClassValue {
35+
ClassValue base;
36+
37+
DjangoContentResponseClass() {
38+
(
39+
// version 1.x
40+
base = Value::named("django.http.response.HttpResponse")
41+
or
42+
// version 2.x and 3.x
43+
// https://docs.djangoproject.com/en/2.2/ref/request-response/#httpresponse-objects
44+
base = Value::named("django.http.HttpResponse")
45+
) and
46+
this.getASuperType() = base
47+
}
48+
49+
// The reason these two methods are defined in this class (and not in the Sink
50+
// definition that uses this class), is that if we were to add support for
51+
// `django.http.response.HttpResponseNotAllowed` it would make much more sense to add
52+
// the custom logic in this class (or subclass), than to handle all of it in the sink
53+
// definition.
54+
/** Gets the `content` argument of a `call` to the constructor */
55+
ControlFlowNode getContentArg(CallNode call) { none() }
56+
57+
/** Gets the `content_type` argument of a `call` to the constructor */
58+
ControlFlowNode getContentTypeArg(CallNode call) { none() }
59+
}
60+
61+
/** A class that is a Django Response, and is vulnerable to XSS. */
62+
class DjangoXSSVulnerableResponseClass extends DjangoContentResponseClass {
63+
DjangoXSSVulnerableResponseClass() {
64+
// We want to avoid FPs on subclasses that are not exposed to XSS, for example `JsonResponse`.
65+
// The easiest way is to disregard any subclass that has a special `__init__` method.
66+
// It's not guaranteed to remove all FPs, or not to generate FNs, but compared to our
67+
// previous implementation that would treat 0-th argument to _any_ subclass as a sink,
68+
// this gets us much closer to reality.
69+
this.lookup("__init__") = base.lookup("__init__") and
70+
not this instanceof DjangoRedirectResponseClass
71+
}
72+
73+
override ControlFlowNode getContentArg(CallNode call) {
74+
result = call.getArg(0)
75+
or
76+
result = call.getArgByName("content")
77+
}
78+
79+
override ControlFlowNode getContentTypeArg(CallNode call) {
80+
result = call.getArg(1)
81+
or
82+
result = call.getArgByName("content_type")
83+
}
84+
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1-
| test_1x.py:13:21:13:24 | django.redirect | externally controlled string |
2-
| test_2x_3x.py:13:21:13:24 | django.redirect | externally controlled string |
1+
| test_1x.py:13:21:13:24 | DjangoShortcutsRedirectSink | externally controlled string |
2+
| test_2x_3x.py:13:21:13:24 | DjangoShortcutsRedirectSink | externally controlled string |
3+
| views_1x.py:99:33:99:55 | DjangoRedirectResponseSink | externally controlled string |
4+
| views_2x_3x.py:120:33:120:55 | DjangoRedirectResponseSink | externally controlled string |

python/ql/test/library-tests/web/django/HttpResponseSinks.expected

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
| views_1x.py:45:25:45:70 | django.Response(...) | externally controlled string |
99
| views_1x.py:66:25:66:55 | django.Response(...) | externally controlled string |
1010
| views_1x.py:75:25:75:33 | django.Response(...) | externally controlled string |
11+
| views_1x.py:90:25:90:33 | django.Response(...) | externally controlled string |
12+
| views_1x.py:94:25:94:58 | django.Response(...) | externally controlled string |
13+
| views_1x.py:103:33:103:55 | django.Response(...) | externally controlled string |
14+
| views_1x.py:107:25:107:47 | django.Response(...) | externally controlled string |
1115
| views_2x_3x.py:8:25:8:63 | django.Response(...) | externally controlled string |
1216
| views_2x_3x.py:12:25:12:52 | django.Response(...) | externally controlled string |
1317
| views_2x_3x.py:16:25:16:53 | django.Response(...) | externally controlled string |
@@ -21,3 +25,7 @@
2125
| views_2x_3x.py:82:25:82:69 | django.Response(...) | externally controlled string |
2226
| views_2x_3x.py:85:25:85:64 | django.Response(...) | externally controlled string |
2327
| views_2x_3x.py:88:25:88:32 | django.Response(...) | externally controlled string |
28+
| views_2x_3x.py:111:25:111:33 | django.Response(...) | externally controlled string |
29+
| views_2x_3x.py:115:25:115:58 | django.Response(...) | externally controlled string |
30+
| views_2x_3x.py:124:33:124:55 | django.Response(...) | externally controlled string |
31+
| views_2x_3x.py:128:25:128:47 | django.Response(...) | externally controlled string |

python/ql/test/library-tests/web/django/views_1x.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""test of views for Django 1.x"""
22
from django.conf.urls import patterns, url
3-
from django.http.response import HttpResponse
3+
from django.http.response import HttpResponse, HttpResponseRedirect, JsonResponse, HttpResponseNotFound
44
from django.views.generic import View
55

66

@@ -77,3 +77,31 @@ def kw_args(request):
7777
urlpatterns = [
7878
url(view=kw_args, regex=r'^kw_args$')
7979
]
80+
81+
# Not an XSS sink, since the Content-Type is not "text/html"
82+
# FP reported in https://github.com/github/codeql-python-team/issues/38
83+
def fp_json_response(request):
84+
# implicitly sets Content-Type to "application/json"
85+
return JsonResponse({"foo": request.GET.get("foo")})
86+
87+
# Not an XSS sink, since the Content-Type is not "text/html"
88+
def fp_manual_json_response(request):
89+
json_data = '{"json": "{}"}'.format(request.GET.get("foo"))
90+
return HttpResponse(json_data, content_type="application/json")
91+
92+
# Not an XSS sink, since the Content-Type is not "text/html"
93+
def fp_manual_content_type(reuqest):
94+
return HttpResponse('<img src="0" onerror="alert(1)">', content_type="text/plain")
95+
96+
# XSS FP reported in https://github.com/github/codeql/issues/3466
97+
# Note: This should be a open-redirect sink, but not a XSS sink.
98+
def fp_redirect(request):
99+
return HttpResponseRedirect(request.GET.get("next"))
100+
101+
# Ensure that simple subclasses are still vuln to XSS
102+
def tp_not_found(request):
103+
return HttpResponseNotFound(request.GET.get("name"))
104+
105+
# Ensure we still have a XSS sink when manually setting the content_type to HTML
106+
def tp_manual_response_type(request):
107+
return HttpResponse(request.GET.get("name"), content_type="text/html; charset=utf-8")

python/ql/test/library-tests/web/django/views_2x_3x.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""testing views for Django 2.x and 3.x"""
22
from django.urls import path, re_path
3-
from django.http import HttpResponse
3+
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse, HttpResponseNotFound
44
from django.views import View
55

66

@@ -99,24 +99,30 @@ def not_valid_identifier(request):
9999
]
100100

101101

102-
################################################################################
102+
# Not an XSS sink, since the Content-Type is not "text/html"
103+
# FP reported in https://github.com/github/codeql-python-team/issues/38
104+
def fp_json_response(request):
105+
# implicitly sets Content-Type to "application/json"
106+
return JsonResponse({"foo": request.GET.get("foo")})
103107

108+
# Not an XSS sink, since the Content-Type is not "text/html"
109+
def fp_manual_json_response(request):
110+
json_data = '{"json": "{}"}'.format(request.GET.get("foo"))
111+
return HttpResponse(json_data, content_type="application/json")
104112

105-
# We should abort if a decorator is used. As demonstrated below, anything might happen
113+
# Not an XSS sink, since the Content-Type is not "text/html"
114+
def fp_manual_content_type(reuqest):
115+
return HttpResponse('<img src="0" onerror="alert(1)">', content_type="text/plain")
106116

107-
# def reverse_kwargs(f):
108-
# @wraps(f)
109-
# def f_(*args, **kwargs):
110-
# new_kwargs = dict()
111-
# for key, value in kwargs.items():
112-
# new_kwargs[key[::-1]] = value
113-
# return f(*args, **new_kwargs)
114-
# return f_
117+
# XSS FP reported in https://github.com/github/codeql/issues/3466
118+
# Note: This should be a open-redirect sink, but not a XSS sink.
119+
def fp_redirect(request):
120+
return HttpResponseRedirect(request.GET.get("next"))
115121

116-
# @reverse_kwargs
117-
# def decorators_can_do_anything(request, oof, foo=None):
118-
# return HttpResponse('This is a mess'[::-1])
122+
# Ensure that simple subclasses are still vuln to XSS
123+
def tp_not_found(request):
124+
return HttpResponseNotFound(request.GET.get("name"))
119125

120-
# urlpatterns = [
121-
# path('rev/<foo>', decorators_can_do_anything),
122-
# ]
126+
# Ensure we still have a XSS sink when manually setting the content_type to HTML
127+
def tp_manual_response_type(request):
128+
return HttpResponse(request.GET.get("name"), content_type="text/html; charset=utf-8")
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
from .response import HttpResponse
1+
from .response import *
22
from .request import HttpRequest

0 commit comments

Comments
 (0)