mirror of
https://github.com/django/django.git
synced 2024-12-22 09:05:43 +00:00
Fixed CVE-2024-39330 -- Added extra file name validation in Storage's save method.
Thanks to Josh Schneier for the report, and to Carlton Gibson and Sarah Boyce for the reviews.
This commit is contained in:
parent
5d86458579
commit
fe4a0bbe20
@ -34,7 +34,18 @@ class Storage:
|
|||||||
if not hasattr(content, "chunks"):
|
if not hasattr(content, "chunks"):
|
||||||
content = File(content, name)
|
content = File(content, name)
|
||||||
|
|
||||||
|
# Ensure that the name is valid, before and after having the storage
|
||||||
|
# system potentially modifying the name. This duplicates the check made
|
||||||
|
# inside `get_available_name` but it's necessary for those cases where
|
||||||
|
# `get_available_name` is overriden and validation is lost.
|
||||||
|
validate_file_name(name, allow_relative_path=True)
|
||||||
|
|
||||||
|
# Potentially find a different name depending on storage constraints.
|
||||||
name = self.get_available_name(name, max_length=max_length)
|
name = self.get_available_name(name, max_length=max_length)
|
||||||
|
# Validate the (potentially) new name.
|
||||||
|
validate_file_name(name, allow_relative_path=True)
|
||||||
|
|
||||||
|
# The save operation should return the actual name of the file saved.
|
||||||
name = self._save(name, content)
|
name = self._save(name, content)
|
||||||
# Ensure that the name returned from the storage system is still valid.
|
# Ensure that the name returned from the storage system is still valid.
|
||||||
validate_file_name(name, allow_relative_path=True)
|
validate_file_name(name, allow_relative_path=True)
|
||||||
|
@ -10,10 +10,9 @@ def validate_file_name(name, allow_relative_path=False):
|
|||||||
raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
|
raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
|
||||||
|
|
||||||
if allow_relative_path:
|
if allow_relative_path:
|
||||||
# Use PurePosixPath() because this branch is checked only in
|
# Ensure that name can be treated as a pure posix path, i.e. Unix
|
||||||
# FileField.generate_filename() where all file paths are expected to be
|
# style (with forward slashes).
|
||||||
# Unix style (with forward slashes).
|
path = pathlib.PurePosixPath(str(name).replace("\\", "/"))
|
||||||
path = pathlib.PurePosixPath(name)
|
|
||||||
if path.is_absolute() or ".." in path.parts:
|
if path.is_absolute() or ".." in path.parts:
|
||||||
raise SuspiciousFileOperation(
|
raise SuspiciousFileOperation(
|
||||||
"Detected path traversal attempt in '%s'" % name
|
"Detected path traversal attempt in '%s'" % name
|
||||||
|
@ -20,3 +20,15 @@ CVE-2024-39329: Username enumeration through timing difference for users with un
|
|||||||
The :meth:`~django.contrib.auth.backends.ModelBackend.authenticate()` method
|
The :meth:`~django.contrib.auth.backends.ModelBackend.authenticate()` method
|
||||||
allowed remote attackers to enumerate users via a timing attack involving login
|
allowed remote attackers to enumerate users via a timing attack involving login
|
||||||
requests for users with unusable passwords.
|
requests for users with unusable passwords.
|
||||||
|
|
||||||
|
CVE-2024-39330: Potential directory-traversal via ``Storage.save()``
|
||||||
|
====================================================================
|
||||||
|
|
||||||
|
Derived classes of the :class:`~django.core.files.storage.Storage` base class
|
||||||
|
which override :meth:`generate_filename()
|
||||||
|
<django.core.files.storage.Storage.generate_filename()>` without replicating
|
||||||
|
the file path validations existing in the parent class, allowed for potential
|
||||||
|
directory-traversal via certain inputs when calling :meth:`save()
|
||||||
|
<django.core.files.storage.Storage.save()>`.
|
||||||
|
|
||||||
|
Built-in ``Storage`` sub-classes were not affected by this vulnerability.
|
||||||
|
@ -21,6 +21,18 @@ The :meth:`~django.contrib.auth.backends.ModelBackend.authenticate()` method
|
|||||||
allowed remote attackers to enumerate users via a timing attack involving login
|
allowed remote attackers to enumerate users via a timing attack involving login
|
||||||
requests for users with unusable passwords.
|
requests for users with unusable passwords.
|
||||||
|
|
||||||
|
CVE-2024-39330: Potential directory-traversal via ``Storage.save()``
|
||||||
|
====================================================================
|
||||||
|
|
||||||
|
Derived classes of the :class:`~django.core.files.storage.Storage` base class
|
||||||
|
which override :meth:`generate_filename()
|
||||||
|
<django.core.files.storage.Storage.generate_filename()>` without replicating
|
||||||
|
the file path validations existing in the parent class, allowed for potential
|
||||||
|
directory-traversal via certain inputs when calling :meth:`save()
|
||||||
|
<django.core.files.storage.Storage.save()>`.
|
||||||
|
|
||||||
|
Built-in ``Storage`` sub-classes were not affected by this vulnerability.
|
||||||
|
|
||||||
Bugfixes
|
Bugfixes
|
||||||
========
|
========
|
||||||
|
|
||||||
|
72
tests/file_storage/test_base.py
Normal file
72
tests/file_storage/test_base.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import os
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.exceptions import SuspiciousFileOperation
|
||||||
|
from django.core.files.storage import Storage
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class CustomStorage(Storage):
|
||||||
|
"""Simple Storage subclass implementing the bare minimum for testing."""
|
||||||
|
|
||||||
|
def exists(self, name):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _save(self, name):
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
class StorageValidateFileNameTests(SimpleTestCase):
|
||||||
|
|
||||||
|
invalid_file_names = [
|
||||||
|
os.path.join("path", "to", os.pardir, "test.file"),
|
||||||
|
os.path.join(os.path.sep, "path", "to", "test.file"),
|
||||||
|
]
|
||||||
|
error_msg = "Detected path traversal attempt in '%s'"
|
||||||
|
|
||||||
|
def test_validate_before_get_available_name(self):
|
||||||
|
s = CustomStorage()
|
||||||
|
# The initial name passed to `save` is not valid nor safe, fail early.
|
||||||
|
for name in self.invalid_file_names:
|
||||||
|
with (
|
||||||
|
self.subTest(name=name),
|
||||||
|
mock.patch.object(s, "get_available_name") as mock_get_available_name,
|
||||||
|
mock.patch.object(s, "_save") as mock_internal_save,
|
||||||
|
):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
SuspiciousFileOperation, self.error_msg % name
|
||||||
|
):
|
||||||
|
s.save(name, content="irrelevant")
|
||||||
|
self.assertEqual(mock_get_available_name.mock_calls, [])
|
||||||
|
self.assertEqual(mock_internal_save.mock_calls, [])
|
||||||
|
|
||||||
|
def test_validate_after_get_available_name(self):
|
||||||
|
s = CustomStorage()
|
||||||
|
# The initial name passed to `save` is valid and safe, but the returned
|
||||||
|
# name from `get_available_name` is not.
|
||||||
|
for name in self.invalid_file_names:
|
||||||
|
with (
|
||||||
|
self.subTest(name=name),
|
||||||
|
mock.patch.object(s, "get_available_name", return_value=name),
|
||||||
|
mock.patch.object(s, "_save") as mock_internal_save,
|
||||||
|
):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
SuspiciousFileOperation, self.error_msg % name
|
||||||
|
):
|
||||||
|
s.save("valid-file-name.txt", content="irrelevant")
|
||||||
|
self.assertEqual(mock_internal_save.mock_calls, [])
|
||||||
|
|
||||||
|
def test_validate_after_internal_save(self):
|
||||||
|
s = CustomStorage()
|
||||||
|
# The initial name passed to `save` is valid and safe, but the result
|
||||||
|
# from `_save` is not (this is achieved by monkeypatching _save).
|
||||||
|
for name in self.invalid_file_names:
|
||||||
|
with (
|
||||||
|
self.subTest(name=name),
|
||||||
|
mock.patch.object(s, "_save", return_value=name),
|
||||||
|
):
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
SuspiciousFileOperation, self.error_msg % name
|
||||||
|
):
|
||||||
|
s.save("valid-file-name.txt", content="irrelevant")
|
@ -288,22 +288,17 @@ class FileStorageTests(SimpleTestCase):
|
|||||||
|
|
||||||
self.storage.delete("path/to/test.file")
|
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)
|
|
||||||
|
|
||||||
@unittest.skipUnless(
|
@unittest.skipUnless(
|
||||||
symlinks_supported(), "Must be able to symlink to run this test."
|
symlinks_supported(), "Must be able to symlink to run this test."
|
||||||
)
|
)
|
||||||
def test_file_save_broken_symlink(self):
|
def test_file_save_broken_symlink(self):
|
||||||
"""A new path is created on save when a broken symlink is supplied."""
|
"""A new path is created on save when a broken symlink is supplied."""
|
||||||
nonexistent_file_path = os.path.join(self.temp_dir, "nonexistent.txt")
|
nonexistent_file_path = os.path.join(self.temp_dir, "nonexistent.txt")
|
||||||
broken_symlink_path = os.path.join(self.temp_dir, "symlink.txt")
|
broken_symlink_file_name = "symlink.txt"
|
||||||
|
broken_symlink_path = os.path.join(self.temp_dir, broken_symlink_file_name)
|
||||||
os.symlink(nonexistent_file_path, broken_symlink_path)
|
os.symlink(nonexistent_file_path, broken_symlink_path)
|
||||||
f = ContentFile("some content")
|
f = ContentFile("some content")
|
||||||
f_name = self.storage.save(broken_symlink_path, f)
|
f_name = self.storage.save(broken_symlink_file_name, f)
|
||||||
self.assertIs(os.path.exists(os.path.join(self.temp_dir, f_name)), True)
|
self.assertIs(os.path.exists(os.path.join(self.temp_dir, f_name)), True)
|
||||||
|
|
||||||
def test_save_doesnt_close(self):
|
def test_save_doesnt_close(self):
|
||||||
|
@ -880,7 +880,7 @@ class DirectoryCreationTests(SimpleTestCase):
|
|||||||
default_storage.delete(UPLOAD_TO)
|
default_storage.delete(UPLOAD_TO)
|
||||||
# Create a file with the upload directory name
|
# Create a file with the upload directory name
|
||||||
with SimpleUploadedFile(UPLOAD_TO, b"x") as file:
|
with SimpleUploadedFile(UPLOAD_TO, b"x") as file:
|
||||||
default_storage.save(UPLOAD_TO, file)
|
default_storage.save(UPLOAD_FOLDER, file)
|
||||||
self.addCleanup(default_storage.delete, UPLOAD_TO)
|
self.addCleanup(default_storage.delete, UPLOAD_TO)
|
||||||
msg = "%s exists and is not a directory." % UPLOAD_TO
|
msg = "%s exists and is not a directory." % UPLOAD_TO
|
||||||
with self.assertRaisesMessage(FileExistsError, msg):
|
with self.assertRaisesMessage(FileExistsError, msg):
|
||||||
|
Loading…
Reference in New Issue
Block a user