Skip to content

Commit cca0d98

Browse files
committed
[3.1.x] Fixed CVE-2021-28658 -- Fixed potential directory-traversal via uploaded files.
Thanks Claude Paroz for the initial patch. Thanks Dennis Brinkrolf for the report. Backport of d4d800c from main.
1 parent 6eb01cb commit cca0d98

File tree

9 files changed

+161
-24
lines changed

9 files changed

+161
-24
lines changed

django/http/multipartparser.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,8 @@ def parse(self):
209209
# This is a file, use the handler...
210210
file_name = disposition.get('filename')
211211
if file_name:
212-
file_name = os.path.basename(file_name)
213212
file_name = force_str(file_name, encoding, errors='replace')
214-
file_name = self.IE_sanitize(html.unescape(file_name))
213+
file_name = self.sanitize_file_name(file_name)
215214
if not file_name:
216215
continue
217216

@@ -299,9 +298,13 @@ def handle_file_complete(self, old_field_name, counters):
299298
self._files.appendlist(force_str(old_field_name, self._encoding, errors='replace'), file_obj)
300299
break
301300

302-
def IE_sanitize(self, filename):
303-
"""Cleanup filename from Internet Explorer full paths."""
304-
return filename and filename[filename.rfind("\\") + 1:].strip()
301+
def sanitize_file_name(self, file_name):
302+
file_name = html.unescape(file_name)
303+
# Cleanup Windows-style path separators.
304+
file_name = file_name[file_name.rfind('\\') + 1:].strip()
305+
return os.path.basename(file_name)
306+
307+
IE_sanitize = sanitize_file_name
305308

306309
def _close_files(self):
307310
# Free up all file handles.

docs/releases/2.2.20.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
===========================
2+
Django 2.2.20 release notes
3+
===========================
4+
5+
*April 6, 2021*
6+
7+
Django 2.2.20 fixes a security issue with severity "low" in 2.2.19.
8+
9+
CVE-2021-28658: Potential directory-traversal via uploaded files
10+
================================================================
11+
12+
``MultiPartParser`` allowed directory-traversal via uploaded files with
13+
suitably crafted file names.
14+
15+
Built-in upload handlers were not affected by this vulnerability.

docs/releases/3.0.14.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
===========================
2+
Django 3.0.14 release notes
3+
===========================
4+
5+
*April 6, 2021*
6+
7+
Django 3.0.14 fixes a security issue with severity "low" in 3.0.13.
8+
9+
CVE-2021-28658: Potential directory-traversal via uploaded files
10+
================================================================
11+
12+
``MultiPartParser`` allowed directory-traversal via uploaded files with
13+
suitably crafted file names.
14+
15+
Built-in upload handlers were not affected by this vulnerability.

docs/releases/3.1.8.txt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@
22
Django 3.1.8 release notes
33
==========================
44

5-
*Expected April 5, 2021*
5+
*April 6, 2021*
66

7-
Django 3.1.8 fixes several bugs in 3.1.7.
7+
Django 3.1.8 fixes a security issue with severity "low" and a bug in 3.1.7.
8+
9+
CVE-2021-28658: Potential directory-traversal via uploaded files
10+
================================================================
11+
12+
``MultiPartParser`` allowed directory-traversal via uploaded files with
13+
suitably crafted file names.
14+
15+
Built-in upload handlers were not affected by this vulnerability.
816

917
Bugfixes
1018
========

docs/releases/index.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ versions of the documentation contain the release notes for any later releases.
4040
.. toctree::
4141
:maxdepth: 1
4242

43+
3.0.14
4344
3.0.13
4445
3.0.12
4546
3.0.11
@@ -60,6 +61,7 @@ versions of the documentation contain the release notes for any later releases.
6061
.. toctree::
6162
:maxdepth: 1
6263

64+
2.2.20
6365
2.2.19
6466
2.2.18
6567
2.2.17

tests/file_uploads/tests.py

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@
2222
MEDIA_ROOT = sys_tempfile.mkdtemp()
2323
UPLOAD_TO = os.path.join(MEDIA_ROOT, 'test_upload')
2424

25+
CANDIDATE_TRAVERSAL_FILE_NAMES = [
26+
'/tmp/hax0rd.txt', # Absolute path, *nix-style.
27+
'C:\\Windows\\hax0rd.txt', # Absolute path, win-style.
28+
'C:/Windows/hax0rd.txt', # Absolute path, broken-style.
29+
'\\tmp\\hax0rd.txt', # Absolute path, broken in a different way.
30+
'/tmp\\hax0rd.txt', # Absolute path, broken by mixing.
31+
'subdir/hax0rd.txt', # Descendant path, *nix-style.
32+
'subdir\\hax0rd.txt', # Descendant path, win-style.
33+
'sub/dir\\hax0rd.txt', # Descendant path, mixed.
34+
'../../hax0rd.txt', # Relative path, *nix-style.
35+
'..\\..\\hax0rd.txt', # Relative path, win-style.
36+
'../..\\hax0rd.txt', # Relative path, mixed.
37+
'../hax0rd.txt', # HTML entities.
38+
'../hax0rd.txt', # HTML entities.
39+
]
40+
2541

2642
@override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE=[])
2743
class FileUploadTests(TestCase):
@@ -250,22 +266,8 @@ def test_dangerous_file_names(self):
250266
# a malicious payload with an invalid file name (containing os.sep or
251267
# os.pardir). This similar to what an attacker would need to do when
252268
# trying such an attack.
253-
scary_file_names = [
254-
"/tmp/hax0rd.txt", # Absolute path, *nix-style.
255-
"C:\\Windows\\hax0rd.txt", # Absolute path, win-style.
256-
"C:/Windows/hax0rd.txt", # Absolute path, broken-style.
257-
"\\tmp\\hax0rd.txt", # Absolute path, broken in a different way.
258-
"/tmp\\hax0rd.txt", # Absolute path, broken by mixing.
259-
"subdir/hax0rd.txt", # Descendant path, *nix-style.
260-
"subdir\\hax0rd.txt", # Descendant path, win-style.
261-
"sub/dir\\hax0rd.txt", # Descendant path, mixed.
262-
"../../hax0rd.txt", # Relative path, *nix-style.
263-
"..\\..\\hax0rd.txt", # Relative path, win-style.
264-
"../..\\hax0rd.txt" # Relative path, mixed.
265-
]
266-
267269
payload = client.FakePayload()
268-
for i, name in enumerate(scary_file_names):
270+
for i, name in enumerate(CANDIDATE_TRAVERSAL_FILE_NAMES):
269271
payload.write('\r\n'.join([
270272
'--' + client.BOUNDARY,
271273
'Content-Disposition: form-data; name="file%s"; filename="%s"' % (i, name),
@@ -285,7 +287,7 @@ def test_dangerous_file_names(self):
285287
response = self.client.request(**r)
286288
# The filenames should have been sanitized by the time it got to the view.
287289
received = response.json()
288-
for i, name in enumerate(scary_file_names):
290+
for i, name in enumerate(CANDIDATE_TRAVERSAL_FILE_NAMES):
289291
got = received["file%s" % i]
290292
self.assertEqual(got, "hax0rd.txt")
291293

@@ -564,6 +566,47 @@ def test_filename_case_preservation(self):
564566
# shouldn't differ.
565567
self.assertEqual(os.path.basename(obj.testfile.path), 'MiXeD_cAsE.txt')
566568

569+
def test_filename_traversal_upload(self):
570+
os.makedirs(UPLOAD_TO, exist_ok=True)
571+
self.addCleanup(shutil.rmtree, MEDIA_ROOT)
572+
tests = [
573+
'../test.txt',
574+
'../test.txt',
575+
]
576+
for file_name in tests:
577+
with self.subTest(file_name=file_name):
578+
payload = client.FakePayload()
579+
payload.write(
580+
'\r\n'.join([
581+
'--' + client.BOUNDARY,
582+
'Content-Disposition: form-data; name="my_file"; '
583+
'filename="%s";' % file_name,
584+
'Content-Type: text/plain',
585+
'',
586+
'file contents.\r\n',
587+
'\r\n--' + client.BOUNDARY + '--\r\n',
588+
]),
589+
)
590+
r = {
591+
'CONTENT_LENGTH': len(payload),
592+
'CONTENT_TYPE': client.MULTIPART_CONTENT,
593+
'PATH_INFO': '/upload_traversal/',
594+
'REQUEST_METHOD': 'POST',
595+
'wsgi.input': payload,
596+
}
597+
response = self.client.request(**r)
598+
result = response.json()
599+
self.assertEqual(response.status_code, 200)
600+
self.assertEqual(result['file_name'], 'test.txt')
601+
self.assertIs(
602+
os.path.exists(os.path.join(MEDIA_ROOT, 'test.txt')),
603+
False,
604+
)
605+
self.assertIs(
606+
os.path.exists(os.path.join(UPLOAD_TO, 'test.txt')),
607+
True,
608+
)
609+
567610

568611
@override_settings(MEDIA_ROOT=MEDIA_ROOT)
569612
class DirectoryCreationTests(SimpleTestCase):
@@ -633,6 +676,15 @@ def test_bad_type_content_length(self):
633676
}, StringIO('x'), [], 'utf-8')
634677
self.assertEqual(multipart_parser._content_length, 0)
635678

679+
def test_sanitize_file_name(self):
680+
parser = MultiPartParser({
681+
'CONTENT_TYPE': 'multipart/form-data; boundary=_foo',
682+
'CONTENT_LENGTH': '1'
683+
}, StringIO('x'), [], 'utf-8')
684+
for file_name in CANDIDATE_TRAVERSAL_FILE_NAMES:
685+
with self.subTest(file_name=file_name):
686+
self.assertEqual(parser.sanitize_file_name(file_name), 'hax0rd.txt')
687+
636688
def test_rfc2231_parsing(self):
637689
test_data = (
638690
(b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A",

tests/file_uploads/uploadhandler.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""
22
Upload handlers to test the upload API.
33
"""
4+
import os
5+
from tempfile import NamedTemporaryFile
46

57
from django.core.files.uploadhandler import FileUploadHandler, StopUpload
68

@@ -35,3 +37,32 @@ class ErroringUploadHandler(FileUploadHandler):
3537
"""A handler that raises an exception."""
3638
def receive_data_chunk(self, raw_data, start):
3739
raise CustomUploadError("Oops!")
40+
41+
42+
class TraversalUploadHandler(FileUploadHandler):
43+
"""A handler with potential directory-traversal vulnerability."""
44+
def __init__(self, request=None):
45+
from .views import UPLOAD_TO
46+
47+
super().__init__(request)
48+
self.upload_dir = UPLOAD_TO
49+
50+
def file_complete(self, file_size):
51+
self.file.seek(0)
52+
self.file.size = file_size
53+
with open(os.path.join(self.upload_dir, self.file_name), 'wb') as fp:
54+
fp.write(self.file.read())
55+
return self.file
56+
57+
def new_file(
58+
self, field_name, file_name, content_type, content_length, charset=None,
59+
content_type_extra=None,
60+
):
61+
super().new_file(
62+
file_name, file_name, content_length, content_length, charset,
63+
content_type_extra,
64+
)
65+
self.file = NamedTemporaryFile(suffix='.upload', dir=self.upload_dir)
66+
67+
def receive_data_chunk(self, raw_data, start):
68+
self.file.write(raw_data)

tests/file_uploads/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
urlpatterns = [
66
path('upload/', views.file_upload_view),
7+
path('upload_traversal/', views.file_upload_traversal_view),
78
path('verify/', views.file_upload_view_verify),
89
path('unicode_name/', views.file_upload_unicode_name),
910
path('echo/', views.file_upload_echo),

tests/file_uploads/views.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
from .models import FileModel
88
from .tests import UNICODE_FILENAME, UPLOAD_TO
9-
from .uploadhandler import ErroringUploadHandler, QuotaUploadHandler
9+
from .uploadhandler import (
10+
ErroringUploadHandler, QuotaUploadHandler, TraversalUploadHandler,
11+
)
1012

1113

1214
def file_upload_view(request):
@@ -141,3 +143,11 @@ def file_upload_fd_closing(request, access):
141143
if access == 't':
142144
request.FILES # Trigger file parsing.
143145
return HttpResponse()
146+
147+
148+
def file_upload_traversal_view(request):
149+
request.upload_handlers.insert(0, TraversalUploadHandler())
150+
request.FILES # Trigger file parsing.
151+
return JsonResponse(
152+
{'file_name': request.upload_handlers[0].file_name},
153+
)

0 commit comments

Comments
 (0)