Skip to content

Commit

Permalink
[3.1.x] Fixed CVE-2021-28658 -- Fixed potential directory-traversal v…
Browse files Browse the repository at this point in the history
…ia uploaded files.

Thanks Claude Paroz for the initial patch.
Thanks Dennis Brinkrolf for the report.

Backport of d4d800c from main.
  • Loading branch information
felixxm committed Apr 6, 2021
1 parent 6eb01cb commit cca0d98
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 24 deletions.
13 changes: 8 additions & 5 deletions django/http/multipartparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,8 @@ def parse(self):
# This is a file, use the handler...
file_name = disposition.get('filename')
if file_name:
file_name = os.path.basename(file_name)
file_name = force_str(file_name, encoding, errors='replace')
file_name = self.IE_sanitize(html.unescape(file_name))
file_name = self.sanitize_file_name(file_name)
if not file_name:
continue

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

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

IE_sanitize = sanitize_file_name

def _close_files(self):
# Free up all file handles.
Expand Down
15 changes: 15 additions & 0 deletions docs/releases/2.2.20.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
===========================
Django 2.2.20 release notes
===========================

*April 6, 2021*

Django 2.2.20 fixes a security issue with severity "low" in 2.2.19.

CVE-2021-28658: Potential directory-traversal via uploaded files
================================================================

``MultiPartParser`` allowed directory-traversal via uploaded files with
suitably crafted file names.

Built-in upload handlers were not affected by this vulnerability.
15 changes: 15 additions & 0 deletions docs/releases/3.0.14.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
===========================
Django 3.0.14 release notes
===========================

*April 6, 2021*

Django 3.0.14 fixes a security issue with severity "low" in 3.0.13.

CVE-2021-28658: Potential directory-traversal via uploaded files
================================================================

``MultiPartParser`` allowed directory-traversal via uploaded files with
suitably crafted file names.

Built-in upload handlers were not affected by this vulnerability.
12 changes: 10 additions & 2 deletions docs/releases/3.1.8.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@
Django 3.1.8 release notes
==========================

*Expected April 5, 2021*
*April 6, 2021*

Django 3.1.8 fixes several bugs in 3.1.7.
Django 3.1.8 fixes a security issue with severity "low" and a bug in 3.1.7.

CVE-2021-28658: Potential directory-traversal via uploaded files
================================================================

``MultiPartParser`` allowed directory-traversal via uploaded files with
suitably crafted file names.

Built-in upload handlers were not affected by this vulnerability.

Bugfixes
========
Expand Down
2 changes: 2 additions & 0 deletions docs/releases/index.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree::
:maxdepth: 1

3.0.14
3.0.13
3.0.12
3.0.11
Expand All @@ -60,6 +61,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree::
:maxdepth: 1

2.2.20
2.2.19
2.2.18
2.2.17
Expand Down
84 changes: 68 additions & 16 deletions tests/file_uploads/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@
MEDIA_ROOT = sys_tempfile.mkdtemp()
UPLOAD_TO = os.path.join(MEDIA_ROOT, 'test_upload')

CANDIDATE_TRAVERSAL_FILE_NAMES = [
'/tmp/hax0rd.txt', # Absolute path, *nix-style.
'C:\\Windows\\hax0rd.txt', # Absolute path, win-style.
'C:/Windows/hax0rd.txt', # Absolute path, broken-style.
'\\tmp\\hax0rd.txt', # Absolute path, broken in a different way.
'/tmp\\hax0rd.txt', # Absolute path, broken by mixing.
'subdir/hax0rd.txt', # Descendant path, *nix-style.
'subdir\\hax0rd.txt', # Descendant path, win-style.
'sub/dir\\hax0rd.txt', # Descendant path, mixed.
'../../hax0rd.txt', # Relative path, *nix-style.
'..\\..\\hax0rd.txt', # Relative path, win-style.
'../..\\hax0rd.txt', # Relative path, mixed.
'../hax0rd.txt', # HTML entities.
'../hax0rd.txt', # HTML entities.
]


@override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE=[])
class FileUploadTests(TestCase):
Expand Down Expand Up @@ -250,22 +266,8 @@ def test_dangerous_file_names(self):
# a malicious payload with an invalid file name (containing os.sep or
# os.pardir). This similar to what an attacker would need to do when
# trying such an attack.
scary_file_names = [
"/tmp/hax0rd.txt", # Absolute path, *nix-style.
"C:\\Windows\\hax0rd.txt", # Absolute path, win-style.
"C:/Windows/hax0rd.txt", # Absolute path, broken-style.
"\\tmp\\hax0rd.txt", # Absolute path, broken in a different way.
"/tmp\\hax0rd.txt", # Absolute path, broken by mixing.
"subdir/hax0rd.txt", # Descendant path, *nix-style.
"subdir\\hax0rd.txt", # Descendant path, win-style.
"sub/dir\\hax0rd.txt", # Descendant path, mixed.
"../../hax0rd.txt", # Relative path, *nix-style.
"..\\..\\hax0rd.txt", # Relative path, win-style.
"../..\\hax0rd.txt" # Relative path, mixed.
]

payload = client.FakePayload()
for i, name in enumerate(scary_file_names):
for i, name in enumerate(CANDIDATE_TRAVERSAL_FILE_NAMES):
payload.write('\r\n'.join([
'--' + client.BOUNDARY,
'Content-Disposition: form-data; name="file%s"; filename="%s"' % (i, name),
Expand All @@ -285,7 +287,7 @@ def test_dangerous_file_names(self):
response = self.client.request(**r)
# The filenames should have been sanitized by the time it got to the view.
received = response.json()
for i, name in enumerate(scary_file_names):
for i, name in enumerate(CANDIDATE_TRAVERSAL_FILE_NAMES):
got = received["file%s" % i]
self.assertEqual(got, "hax0rd.txt")

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

def test_filename_traversal_upload(self):
os.makedirs(UPLOAD_TO, exist_ok=True)
self.addCleanup(shutil.rmtree, MEDIA_ROOT)
tests = [
'../test.txt',
'../test.txt',
]
for file_name in tests:
with self.subTest(file_name=file_name):
payload = client.FakePayload()
payload.write(
'\r\n'.join([
'--' + client.BOUNDARY,
'Content-Disposition: form-data; name="my_file"; '
'filename="%s";' % file_name,
'Content-Type: text/plain',
'',
'file contents.\r\n',
'\r\n--' + client.BOUNDARY + '--\r\n',
]),
)
r = {
'CONTENT_LENGTH': len(payload),
'CONTENT_TYPE': client.MULTIPART_CONTENT,
'PATH_INFO': '/upload_traversal/',
'REQUEST_METHOD': 'POST',
'wsgi.input': payload,
}
response = self.client.request(**r)
result = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(result['file_name'], 'test.txt')
self.assertIs(
os.path.exists(os.path.join(MEDIA_ROOT, 'test.txt')),
False,
)
self.assertIs(
os.path.exists(os.path.join(UPLOAD_TO, 'test.txt')),
True,
)


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

def test_sanitize_file_name(self):
parser = MultiPartParser({
'CONTENT_TYPE': 'multipart/form-data; boundary=_foo',
'CONTENT_LENGTH': '1'
}, StringIO('x'), [], 'utf-8')
for file_name in CANDIDATE_TRAVERSAL_FILE_NAMES:
with self.subTest(file_name=file_name):
self.assertEqual(parser.sanitize_file_name(file_name), 'hax0rd.txt')

def test_rfc2231_parsing(self):
test_data = (
(b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A",
Expand Down
31 changes: 31 additions & 0 deletions tests/file_uploads/uploadhandler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""
Upload handlers to test the upload API.
"""
import os
from tempfile import NamedTemporaryFile

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

Expand Down Expand Up @@ -35,3 +37,32 @@ class ErroringUploadHandler(FileUploadHandler):
"""A handler that raises an exception."""
def receive_data_chunk(self, raw_data, start):
raise CustomUploadError("Oops!")


class TraversalUploadHandler(FileUploadHandler):
"""A handler with potential directory-traversal vulnerability."""
def __init__(self, request=None):
from .views import UPLOAD_TO

super().__init__(request)
self.upload_dir = UPLOAD_TO

def file_complete(self, file_size):
self.file.seek(0)
self.file.size = file_size
with open(os.path.join(self.upload_dir, self.file_name), 'wb') as fp:
fp.write(self.file.read())
return self.file

def new_file(
self, field_name, file_name, content_type, content_length, charset=None,
content_type_extra=None,
):
super().new_file(
file_name, file_name, content_length, content_length, charset,
content_type_extra,
)
self.file = NamedTemporaryFile(suffix='.upload', dir=self.upload_dir)

def receive_data_chunk(self, raw_data, start):
self.file.write(raw_data)
1 change: 1 addition & 0 deletions tests/file_uploads/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

urlpatterns = [
path('upload/', views.file_upload_view),
path('upload_traversal/', views.file_upload_traversal_view),
path('verify/', views.file_upload_view_verify),
path('unicode_name/', views.file_upload_unicode_name),
path('echo/', views.file_upload_echo),
Expand Down
12 changes: 11 additions & 1 deletion tests/file_uploads/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

from .models import FileModel
from .tests import UNICODE_FILENAME, UPLOAD_TO
from .uploadhandler import ErroringUploadHandler, QuotaUploadHandler
from .uploadhandler import (
ErroringUploadHandler, QuotaUploadHandler, TraversalUploadHandler,
)


def file_upload_view(request):
Expand Down Expand Up @@ -141,3 +143,11 @@ def file_upload_fd_closing(request, access):
if access == 't':
request.FILES # Trigger file parsing.
return HttpResponse()


def file_upload_traversal_view(request):
request.upload_handlers.insert(0, TraversalUploadHandler())
request.FILES # Trigger file parsing.
return JsonResponse(
{'file_name': request.upload_handlers[0].file_name},
)

0 comments on commit cca0d98

Please sign in to comment.