From e63994b1e6ab8b66d9c5fc2f4bf5cd8d09e749e1 Mon Sep 17 00:00:00 2001
From: Florian Apolloner <florian@apolloner.eu>
Date: Fri, 17 Dec 2021 21:07:50 +0100
Subject: [PATCH] [2.2.x] Fixed CVE-2021-45452 -- Fixed potential path
 traversal in storage subsystem.

Thanks to Dennis Brinkrolf for the report.
---
 django/core/files/storage.py                 |  9 ++++++++-
 docs/releases/2.2.26.txt                     |  9 +++++++++
 tests/file_storage/test_generate_filename.py | 19 +++++++++++++------
 tests/file_storage/tests.py                  |  6 ++++++
 4 files changed, 36 insertions(+), 7 deletions(-)

Index: Django-1.11.29/django/core/files/storage.py
===================================================================
--- Django-1.11.29.orig/django/core/files/storage.py
+++ Django-1.11.29/django/core/files/storage.py
@@ -53,7 +53,10 @@ class Storage(object):
             content = File(content, name)
 
         name = self.get_available_name(name, max_length=max_length)
-        return self._save(name, content)
+        name = self._save(name, content)
+        # Ensure that the name returned from the storage system is still valid.
+        validate_file_name(name, allow_relative_path=True)
+        return name
 
     # These methods are part of the public API, with default implementations.
 
@@ -69,6 +72,7 @@ class Storage(object):
         Returns a filename that's free on the target storage system, and
         available for new content to be written to.
         """
+        name = str(name).replace('\\', '/')
         dir_name, file_name = os.path.split(name)
         if '..' in pathlib.PurePath(dir_name).parts:
             raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dir_name)
@@ -103,6 +107,7 @@ class Storage(object):
         Validate the filename by calling get_valid_name() and return a filename
         to be passed to the save() method.
         """
+        filename = str(filename).replace('\\', '/')
         # `filename` may include a path as returned by FileField.upload_to.
         dirname, filename = os.path.split(filename)
         if '..' in pathlib.PurePath(dirname).parts:
@@ -380,6 +385,8 @@ class FileSystemStorage(Storage):
         if self.file_permissions_mode is not None:
             os.chmod(full_path, self.file_permissions_mode)
 
+        # Ensure the saved path is always relative to the storage root.
+        name = os.path.relpath(full_path, self.location)
         # Store filenames with forward slashes, even on Windows.
         return force_text(name.replace('\\', '/'))
 
Index: Django-1.11.29/tests/file_storage/test_generate_filename.py
===================================================================
--- Django-1.11.29.orig/tests/file_storage/test_generate_filename.py
+++ Django-1.11.29/tests/file_storage/test_generate_filename.py
@@ -54,13 +54,20 @@ class GenerateFilenameStorageTests(Simpl
                     s.generate_filename(file_name)
 
     def test_storage_dangerous_paths_dir_name(self):
-        file_name = '/tmp/../path'
+        candidates = [
+            ('tmp/../path', 'tmp/..'),
+            ('tmp\\..\\path', 'tmp/..'),
+            ('/tmp/../path', '/tmp/..'),
+            ('\\tmp\\..\\path', '/tmp/..'),
+        ]
         s = FileSystemStorage()
-        msg = "Detected path traversal attempt in '/tmp/..'"
-        with self.assertRaisesMessage(SuspiciousFileOperation, msg):
-            s.get_available_name(file_name)
-        with self.assertRaisesMessage(SuspiciousFileOperation, msg):
-            s.generate_filename(file_name)
+        for file_name, path in candidates:
+            msg = "Detected path traversal attempt in '%s'" % path
+            with self.subTest(file_name=file_name):
+                with self.assertRaisesMessage(SuspiciousFileOperation, msg):
+                    s.get_available_name(file_name)
+                with self.assertRaisesMessage(SuspiciousFileOperation, msg):
+                    s.generate_filename(file_name)
 
     def test_filefield_dangerous_filename(self):
         candidates = ['..', '.', '', '???', '$.$.$']
Index: Django-1.11.29/tests/file_storage/tests.py
===================================================================
--- Django-1.11.29.orig/tests/file_storage/tests.py
+++ Django-1.11.29/tests/file_storage/tests.py
@@ -342,6 +342,12 @@ class FileStorageTests(SimpleTestCase):
 
         self.storage.delete('path/to/test.file')
 
+    def test_file_save_abs_path(self):
+        test_name = 'path/to/test.file'
+        f = ContentFile('file saved with path')
+        f_name = self.storage.save(os.path.join(self.temp_dir, test_name), f)
+        self.assertEqual(f_name, test_name)
+
     def test_save_doesnt_close(self):
         with TemporaryUploadedFile('test', 'text/plain', 1, 'utf8') as file:
             file.write(b'1')
Index: Django-1.11.29/django/core/files/utils.py
===================================================================
--- Django-1.11.29.orig/django/core/files/utils.py
+++ Django-1.11.29/django/core/files/utils.py
@@ -1,18 +1,27 @@
 import os
+import pathlib
 
 from django.core.exceptions import SuspiciousFileOperation
 
 
-def validate_file_name(name):
-    if name != os.path.basename(name):
-        raise SuspiciousFileOperation("File name '%s' includes path elements" % name)
-
+def validate_file_name(name, allow_relative_path=False):
     # Remove potentially dangerous names
-    if name in {'', '.', '..'}:
+    if os.path.basename(name) in {'', '.', '..'}:
         raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
 
-    return name
+    if allow_relative_path:
+        # Use PurePosixPath() because this branch is checked only in
+        # FileField.generate_filename() where all file paths are expected to be
+        # Unix style (with forward slashes).
+        path = pathlib.PurePosixPath(name)
+        if path.is_absolute() or '..' in path.parts:
+            raise SuspiciousFileOperation(
+                "Detected path traversal attempt in '%s'" % name
+            )
+    elif name != os.path.basename(name):
+        raise SuspiciousFileOperation("File name '%s' includes path elements" % name)
 
+    return name
 
 class FileProxyMixin(object):
     """
