Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 2b00edc

Browse files
committed
[4.2.x] 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.
1 parent 156d318 commit 2b00edc

File tree

6 files changed

+100
-13
lines changed

6 files changed

+100
-13
lines changed

django/core/files/storage/base.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,18 @@ def save(self, name, content, max_length=None):
3434
if not hasattr(content, "chunks"):
3535
content = File(content, name)
3636

37+
# Ensure that the name is valid, before and after having the storage
38+
# system potentially modifying the name. This duplicates the check made
39+
# inside `get_available_name` but it's necessary for those cases where
40+
# `get_available_name` is overriden and validation is lost.
41+
validate_file_name(name, allow_relative_path=True)
42+
43+
# Potentially find a different name depending on storage constraints.
3744
name = self.get_available_name(name, max_length=max_length)
45+
# Validate the (potentially) new name.
46+
validate_file_name(name, allow_relative_path=True)
47+
48+
# The save operation should return the actual name of the file saved.
3849
name = self._save(name, content)
3950
# Ensure that the name returned from the storage system is still valid.
4051
validate_file_name(name, allow_relative_path=True)

django/core/files/utils.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,9 @@ def validate_file_name(name, allow_relative_path=False):
1010
raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
1111

1212
if allow_relative_path:
13-
# Use PurePosixPath() because this branch is checked only in
14-
# FileField.generate_filename() where all file paths are expected to be
15-
# Unix style (with forward slashes).
16-
path = pathlib.PurePosixPath(name)
13+
# Ensure that name can be treated as a pure posix path, i.e. Unix
14+
# style (with forward slashes).
15+
path = pathlib.PurePosixPath(str(name).replace("\\", "/"))
1716
if path.is_absolute() or ".." in path.parts:
1817
raise SuspiciousFileOperation(
1918
"Detected path traversal attempt in '%s'" % name

docs/releases/4.2.14.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,15 @@ CVE-2024-39329: Username enumeration through timing difference for users with un
2020
The :meth:`~django.contrib.auth.backends.ModelBackend.authenticate()` method
2121
allowed remote attackers to enumerate users via a timing attack involving login
2222
requests for users with unusable passwords.
23+
24+
CVE-2024-39330: Potential directory-traversal via ``Storage.save()``
25+
====================================================================
26+
27+
Derived classes of the :class:`~django.core.files.storage.Storage` base class
28+
which override :meth:`generate_filename()
29+
<django.core.files.storage.Storage.generate_filename()>` without replicating
30+
the file path validations existing in the parent class, allowed for potential
31+
directory-traversal via certain inputs when calling :meth:`save()
32+
<django.core.files.storage.Storage.save()>`.
33+
34+
Built-in ``Storage`` sub-classes were not affected by this vulnerability.

tests/file_storage/test_base.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import os
2+
from unittest import mock
3+
4+
from django.core.exceptions import SuspiciousFileOperation
5+
from django.core.files.storage import Storage
6+
from django.test import SimpleTestCase
7+
8+
9+
class CustomStorage(Storage):
10+
"""Simple Storage subclass implementing the bare minimum for testing."""
11+
12+
def exists(self, name):
13+
return False
14+
15+
def _save(self, name):
16+
return name
17+
18+
19+
class StorageValidateFileNameTests(SimpleTestCase):
20+
invalid_file_names = [
21+
os.path.join("path", "to", os.pardir, "test.file"),
22+
os.path.join(os.path.sep, "path", "to", "test.file"),
23+
]
24+
error_msg = "Detected path traversal attempt in '%s'"
25+
26+
def test_validate_before_get_available_name(self):
27+
s = CustomStorage()
28+
# The initial name passed to `save` is not valid nor safe, fail early.
29+
for name in self.invalid_file_names:
30+
with (
31+
self.subTest(name=name),
32+
mock.patch.object(s, "get_available_name") as mock_get_available_name,
33+
mock.patch.object(s, "_save") as mock_internal_save,
34+
):
35+
with self.assertRaisesMessage(
36+
SuspiciousFileOperation, self.error_msg % name
37+
):
38+
s.save(name, content="irrelevant")
39+
self.assertEqual(mock_get_available_name.mock_calls, [])
40+
self.assertEqual(mock_internal_save.mock_calls, [])
41+
42+
def test_validate_after_get_available_name(self):
43+
s = CustomStorage()
44+
# The initial name passed to `save` is valid and safe, but the returned
45+
# name from `get_available_name` is not.
46+
for name in self.invalid_file_names:
47+
with (
48+
self.subTest(name=name),
49+
mock.patch.object(s, "get_available_name", return_value=name),
50+
mock.patch.object(s, "_save") as mock_internal_save,
51+
):
52+
with self.assertRaisesMessage(
53+
SuspiciousFileOperation, self.error_msg % name
54+
):
55+
s.save("valid-file-name.txt", content="irrelevant")
56+
self.assertEqual(mock_internal_save.mock_calls, [])
57+
58+
def test_validate_after_internal_save(self):
59+
s = CustomStorage()
60+
# The initial name passed to `save` is valid and safe, but the result
61+
# from `_save` is not (this is achieved by monkeypatching _save).
62+
for name in self.invalid_file_names:
63+
with (
64+
self.subTest(name=name),
65+
mock.patch.object(s, "_save", return_value=name),
66+
):
67+
with self.assertRaisesMessage(
68+
SuspiciousFileOperation, self.error_msg % name
69+
):
70+
s.save("valid-file-name.txt", content="irrelevant")

tests/file_storage/tests.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -342,22 +342,17 @@ def test_file_save_with_path(self):
342342

343343
self.storage.delete("path/to/test.file")
344344

345-
def test_file_save_abs_path(self):
346-
test_name = "path/to/test.file"
347-
f = ContentFile("file saved with path")
348-
f_name = self.storage.save(os.path.join(self.temp_dir, test_name), f)
349-
self.assertEqual(f_name, test_name)
350-
351345
@unittest.skipUnless(
352346
symlinks_supported(), "Must be able to symlink to run this test."
353347
)
354348
def test_file_save_broken_symlink(self):
355349
"""A new path is created on save when a broken symlink is supplied."""
356350
nonexistent_file_path = os.path.join(self.temp_dir, "nonexistent.txt")
357-
broken_symlink_path = os.path.join(self.temp_dir, "symlink.txt")
351+
broken_symlink_file_name = "symlink.txt"
352+
broken_symlink_path = os.path.join(self.temp_dir, broken_symlink_file_name)
358353
os.symlink(nonexistent_file_path, broken_symlink_path)
359354
f = ContentFile("some content")
360-
f_name = self.storage.save(broken_symlink_path, f)
355+
f_name = self.storage.save(broken_symlink_file_name, f)
361356
self.assertIs(os.path.exists(os.path.join(self.temp_dir, f_name)), True)
362357

363358
def test_save_doesnt_close(self):

tests/file_uploads/tests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -826,7 +826,7 @@ def test_not_a_directory(self):
826826
default_storage.delete(UPLOAD_TO)
827827
# Create a file with the upload directory name
828828
with SimpleUploadedFile(UPLOAD_TO, b"x") as file:
829-
default_storage.save(UPLOAD_TO, file)
829+
default_storage.save(UPLOAD_FOLDER, file)
830830
self.addCleanup(default_storage.delete, UPLOAD_TO)
831831
msg = "%s exists and is not a directory." % UPLOAD_TO
832832
with self.assertRaisesMessage(FileExistsError, msg):

0 commit comments

Comments
 (0)