Skip to content

Commit 6169ac6

Browse files
authored
Merge pull request #7776 from RasmusWL/django-filefield-uploadto
Python: Support Django FileField.upload_to
2 parents 6f9752e + c87b308 commit 6169ac6

File tree

8 files changed

+169
-11
lines changed

8 files changed

+169
-11
lines changed

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,38 @@ module PrivateDjango {
737737
}
738738
}
739739

740+
/**
741+
* Provides models for the `django.db.models.FileField` class and `ImageField` subclasses.
742+
*
743+
* See
744+
* - https://docs.djangoproject.com/en/3.1/ref/models/fields/#django.db.models.FileField
745+
* - https://docs.djangoproject.com/en/3.1/ref/models/fields/#django.db.models.ImageField
746+
*/
747+
module FileField {
748+
/** Gets a reference to the `django.db.models.FileField` or the `django.db.models.ImageField` class or any subclass. */
749+
API::Node subclassRef() {
750+
exists(string className | className in ["FileField", "ImageField"] |
751+
// commonly used alias
752+
result =
753+
API::moduleImport("django")
754+
.getMember("db")
755+
.getMember("models")
756+
.getMember(className)
757+
.getASubclass*()
758+
or
759+
// actual class definition
760+
result =
761+
API::moduleImport("django")
762+
.getMember("db")
763+
.getMember("models")
764+
.getMember("fields")
765+
.getMember("files")
766+
.getMember(className)
767+
.getASubclass*()
768+
)
769+
}
770+
}
771+
740772
/**
741773
* Gets a reference to the Manager (django.db.models.Manager) for the django Model `modelClass`,
742774
* accessed by `<modelClass>.objects`.
@@ -2599,6 +2631,36 @@ module PrivateDjango {
25992631
}
26002632
}
26012633

2634+
/**
2635+
* A parameter that accepts the filename used to upload a file. This is the second
2636+
* parameter in functions used for the `upload_to` argument to a `FileField`.
2637+
*
2638+
* Note that the value this parameter accepts cannot contain a slash. Even when
2639+
* forcing the filename to contain a slash when sending the request, django does
2640+
* something like `input_filename.split("/")[-1]` (so other special characters still
2641+
* allowed). This also means that although the return value from `upload_to` is used
2642+
* to construct a path, path injection is not possible.
2643+
*
2644+
* See
2645+
* - https://docs.djangoproject.com/en/3.1/ref/models/fields/#django.db.models.FileField.upload_to
2646+
* - https://docs.djangoproject.com/en/3.1/topics/http/file-uploads/#handling-uploaded-files-with-a-model
2647+
*/
2648+
private class DjangoFileFieldUploadToFunctionFilenameParam extends RemoteFlowSource::Range,
2649+
DataFlow::ParameterNode {
2650+
DjangoFileFieldUploadToFunctionFilenameParam() {
2651+
exists(DataFlow::CallCfgNode call, DataFlow::Node uploadToArg, Function func |
2652+
this.getParameter() = func.getArg(1) and
2653+
call = DjangoImpl::DB::Models::FileField::subclassRef().getACall() and
2654+
uploadToArg in [call.getArg(2), call.getArgByName("upload_to")] and
2655+
uploadToArg = poorMansFunctionTracker(func)
2656+
)
2657+
}
2658+
2659+
override string getSourceType() {
2660+
result = "django filename parameter to function used in FileField.upload_to"
2661+
}
2662+
}
2663+
26022664
// ---------------------------------------------------------------------------
26032665
// django.shortcuts.redirect
26042666
// ---------------------------------------------------------------------------
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
db.sqlite3
2+
3+
# The testapp/migrations/ folder needs to be comitted to git,
4+
# but we don't care to store the actual migrations
5+
testapp/migrations/
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from django.db import models
2+
import django.db.models.fields.files
3+
4+
def custom_path_function_1(instance, filename):
5+
ensure_tainted(filename) # $ tainted
6+
7+
def custom_path_function_2(instance, filename):
8+
ensure_tainted(filename) # $ tainted
9+
10+
def custom_path_function_3(instance, filename):
11+
ensure_tainted(filename) # $ tainted
12+
13+
def custom_path_function_4(instance, filename):
14+
ensure_tainted(filename) # $ tainted
15+
16+
17+
class CustomFileFieldSubclass(models.FileField):
18+
pass
19+
20+
21+
class MyModel(models.Model):
22+
upload_1 = models.FileField(None, None, custom_path_function_1)
23+
upload_2 = django.db.models.fields.files.FileField(upload_to=custom_path_function_2)
24+
25+
upload_3 = models.ImageField(upload_to=custom_path_function_3)
26+
27+
upload_4 = CustomFileFieldSubclass(upload_to=custom_path_function_4)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env python3
2+
3+
# first run the server with
4+
# python manage.py makemigrations && python manage.py migrate && python manage.py runserver
5+
6+
import requests
7+
8+
requests.post(
9+
"http://127.0.0.1:8000/app/file-test/",
10+
files={"fieldname": ("foo/bar", open("/home/rasmus/TODO", "rb"))}
11+
)
12+
13+
requests.post(
14+
"http://127.0.0.1:8000/app/file-test/",
15+
files={"fieldname": ("../bar", open("/home/rasmus/TODO", "rb"))}
16+
)
17+
18+
requests.post(
19+
"http://127.0.0.1:8000/app/file-test/",
20+
files={"fieldname": (r"foo%2fbar", open("/home/rasmus/TODO", "rb"))}
21+
)
22+
23+
requests.post(
24+
"http://127.0.0.1:8000/app/file-test/",
25+
files={"fieldname": (r"%2e%2e%2fbar", open("/home/rasmus/TODO", "rb"))}
26+
)
27+
28+
requests.post(
29+
"http://127.0.0.1:8000/app/file-test/",
30+
files={"fieldname": (r"foo%c0%afbar", open("/home/rasmus/TODO", "rb"))}
31+
)
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import os.path
2+
13
from django.db import models
24

3-
# Create your models here.
5+
def custom_path_function(instance, filename):
6+
print(repr(os.path.join("rootdir", filename)))
7+
raise NotImplementedError()
8+
9+
class MyModel(models.Model):
10+
upload = models.FileField(upload_to=custom_path_function)
Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
from django.urls import path, re_path
22

3-
# This version 1.x way of defining urls is deprecated in Django 3.1, but still works
4-
from django.conf.urls import url
5-
63
from . import views
74

85
urlpatterns = [
@@ -11,11 +8,29 @@
118
# inline expectation tests (which thinks the `$` would mark the beginning of a new
129
# line)
1310
re_path(r"^ba[rz]/", views.bar_baz), # $routeSetup="^ba[rz]/"
14-
url(r"^deprecated/", views.deprecated), # $routeSetup="^deprecated/"
1511

1612
path("basic-view-handler/", views.MyBasicViewHandler.as_view()), # $routeSetup="basic-view-handler/"
1713
path("custom-inheritance-view-handler/", views.MyViewHandlerWithCustomInheritance.as_view()), # $routeSetup="custom-inheritance-view-handler/"
1814

1915
path("CustomRedirectView/<foo>", views.CustomRedirectView.as_view()), # $routeSetup="CustomRedirectView/<foo>"
2016
path("CustomRedirectView2/<foo>", views.CustomRedirectView2.as_view()), # $routeSetup="CustomRedirectView2/<foo>"
17+
18+
path("file-test/", views.file_test), # $routeSetup="file-test/"
2119
]
20+
21+
from django import __version__ as django_version
22+
23+
if django_version[0] == "3":
24+
# This version 1.x way of defining urls is deprecated in Django 3.1, but still works.
25+
# However, it is removed in Django 4.0, so we need this guard to make our code runnable
26+
from django.conf.urls import url
27+
28+
old_urlpatterns = urlpatterns
29+
30+
# we need this assignment to get our logic working... maybe it should be more
31+
# sophisticated?
32+
urlpatterns = [
33+
url(r"^deprecated/", views.deprecated), # $routeSetup="^deprecated/"
34+
]
35+
36+
urlpatterns += old_urlpatterns

python/ql/test/library-tests/frameworks/django-v2-v3/testapp/views.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.views.generic import View, RedirectView
33
from django.views.decorators.csrf import csrf_exempt
44

5+
from .models import MyModel
56

67
def foo(request: HttpRequest): # $requestHandler
78
return HttpResponse("foo") # $HttpResponse
@@ -45,3 +46,13 @@ def get_redirect_url(self, foo): # $ requestHandler routedParameter=foo
4546
class CustomRedirectView2(RedirectView):
4647

4748
url = "https://example.com/%(foo)s"
49+
50+
51+
# Test of FileField upload_to functions
52+
def file_test(request: HttpRequest): # $ requestHandler
53+
model = MyModel(upload=request.FILES['fieldname'])
54+
try:
55+
model.save()
56+
except NotImplementedError:
57+
pass
58+
return HttpResponse("ok") # $ HttpResponse

python/ql/test/library-tests/frameworks/django-v2-v3/testproj/settings.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,12 @@
7474
# Database
7575
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
7676

77-
# DATABASES = {
78-
# 'default': {
79-
# 'ENGINE': 'django.db.backends.sqlite3',
80-
# 'NAME': BASE_DIR / 'db.sqlite3',
81-
# }
82-
# }
77+
DATABASES = {
78+
'default': {
79+
'ENGINE': 'django.db.backends.sqlite3',
80+
'NAME': BASE_DIR / 'db.sqlite3',
81+
}
82+
}
8383

8484

8585
# Password validation

0 commit comments

Comments
 (0)