From cfc6ffc2c948730f98fc1376f02b3b243d3af935 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak <felisiak.mariusz@gmail.com>
Date: Tue, 16 Mar 2021 10:19:00 +0100
Subject: [PATCH] [2.2.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.

Maintainers note: backported from

  0001-2.2.x-Fixed-CVE-2021-28658-Fixed-potential-directory.patch


diff --git a/django/http/multipartparser.py b/django/http/multipartparser.py
index d9e7e4313f0a..2bc9d0e518f6 100644
--- a/django/http/multipartparser.py
+++ b/django/http/multipartparser.py
@@ -9,6 +9,7 @@ from __future__ import unicode_literals
 import base64
 import binascii
 import cgi
+import os
 import sys
 
 from django.conf import settings
@@ -211,7 +212,7 @@ class MultiPartParser(object):
                     file_name = disposition.get('filename')
                     if file_name:
                         file_name = force_text(file_name, encoding, errors='replace')
-                        file_name = self.IE_sanitize(unescape_entities(file_name))
+                        file_name = self.sanitize_file_name(file_name)
                     if not file_name:
                         continue
 
@@ -303,9 +304,13 @@ class MultiPartParser(object):
                 self._files.appendlist(force_text(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 = unescape_entities(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.
diff --git a/tests/file_uploads/tests.py b/tests/file_uploads/tests.py
index 5f6a50250d54..af7b82126cf6 100644
--- a/tests/file_uploads/tests.py
+++ b/tests/file_uploads/tests.py
@@ -27,6 +27,21 @@ UNICODE_FILENAME = 'test-0123456789_中文_Orléans.jpg'
 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.
+    '..&#x2F;hax0rd.txt',       # HTML entities.
+]
+
 
 @override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE=[])
 class FileUploadTests(TestCase):
@@ -222,22 +237,8 @@ class FileUploadTests(TestCase):
         # 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),
@@ -258,7 +259,7 @@ class FileUploadTests(TestCase):
 
         # The filenames should have been sanitized by the time it got to the view.
         received = json.loads(response.content.decode('utf-8'))
-        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")
 
@@ -544,6 +545,36 @@ class FileUploadTests(TestCase):
         # 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)
+        file_name = '..&#x2F;test.txt',
+        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):
@@ -597,6 +628,15 @@ class MultiParserTests(unittest.TestCase):
             'CONTENT_LENGTH': '1'
         }, StringIO('x'), [], 'utf-8')
 
+    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",
diff --git a/tests/file_uploads/uploadhandler.py b/tests/file_uploads/uploadhandler.py
index b69cc751cb02..7ceeded74bd5 100644
--- a/tests/file_uploads/uploadhandler.py
+++ b/tests/file_uploads/uploadhandler.py
@@ -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
 
@@ -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)
diff --git a/tests/file_uploads/urls.py b/tests/file_uploads/urls.py
index 504554483e0d..632ab9969415 100644
--- a/tests/file_uploads/urls.py
+++ b/tests/file_uploads/urls.py
@@ -13,6 +13,7 @@ urlpatterns = [
     url(r'^quota/broken/$', views.file_upload_quota_broken),
     url(r'^getlist_count/$', views.file_upload_getlist_count),
     url(r'^upload_errors/$', views.file_upload_errors),
+    url(r'^upload_traversal/$', views.file_upload_traversal_view),
     url(r'^filename_case/$', views.file_upload_filename_case_view),
     url(r'^fd_closing/(?P<access>t|f)/$', views.file_upload_fd_closing),
 ]
diff --git a/tests/file_uploads/views.py b/tests/file_uploads/views.py
index 17d4a1b0f439..365d7ba41eaa 100644
--- a/tests/file_uploads/views.py
+++ b/tests/file_uploads/views.py
@@ -12,7 +12,9 @@ from django.utils.encoding import force_bytes, force_str
 
 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):
@@ -166,3 +168,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},
+    )
