VYPR
High severityNVD Advisory· Published Jul 10, 2024· Updated Nov 4, 2025

CVE-2024-39330

CVE-2024-39330

Description

An issue was discovered in Django 5.0 before 5.0.7 and 4.2 before 4.2.14. Derived classes of the django.core.files.storage.Storage base class, when they override generate_filename() without replicating the file-path validations from the parent class, potentially allow directory traversal via certain inputs during a save() call. (Built-in Storage sub-classes are unaffected.)

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

CVE-2024-39330 allows directory traversal in Django's Storage.save() if custom subclasses override generate_filename() without replicating parent file-path validations.

Root

Cause CVE-2024-39330 is a directory-traversal vulnerability in the Django web framework, affecting versions 5.0 before 5.0.7 and 4.2 before 4.2.14. The issue lies in the django.core.files.storage.Storage base class. When a developer creates a derived class that overrides the generate_filename() method without reproducing the file-path validations present in the parent class, the save() call can be tricked into writing files outside the intended storage directory [2][3]. Built-in Storage subclasses are not affected because they correctly maintain the validation logic [2][3].

Attack

Vector An attacker can exploit this by providing a specially crafted filename containing path traversal sequences (such as ../ or absolute path components) to a save() call on a vulnerable custom storage backend. No authentication is required if the application endpoint allows unauthenticated file uploads or if the storage code is reached via user-supplied data. The vulnerability does not require network-level access beyond the ability to submit a filename to the affected Django application [2][4].

Impact

Successful exploitation permits an attacker to write files to arbitrary directories on the server's filesystem, potentially overwriting critical system files, placing malicious scripts (e.g., a web shell) in executable directories, or accessing sensitive data. The severity is classified as "low" by the Django security team, likely because it requires a custom storage subclass and a non-default configuration to be present [3].

Mitigation

Django released versions 5.0.7 and 4.2.14 to fix this vulnerability. Users are strongly advised to upgrade to these versions or later. The patch introduces additional validation in the Storage.save() method that checks both the initial name and the name returned by generate_filename() or _save() for path traversal attempts, raising a SuspiciousFileOperation if detected [4]. No workarounds are documented other than upgrading or replicating the validation logic in custom storage subclasses.

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
DjangoPyPI
>= 5.0, < 5.0.75.0.7
DjangoPyPI
>= 4.2, < 4.2.144.2.14

Affected products

31

Patches

4
2b00edc0151a

[4.2.x] Fixed CVE-2024-39330 -- Added extra file name validation in Storage's save method.

https://github.com/django/djangoNataliaMar 20, 2024via ghsa
6 files changed · +100 13
  • django/core/files/storage/base.py+11 0 modified
    @@ -34,7 +34,18 @@ def save(self, name, content, max_length=None):
             if not hasattr(content, "chunks"):
                 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)
    +        # 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)
             # Ensure that the name returned from the storage system is still valid.
             validate_file_name(name, allow_relative_path=True)
    
  • django/core/files/utils.py+3 4 modified
    @@ -10,10 +10,9 @@ def validate_file_name(name, allow_relative_path=False):
             raise SuspiciousFileOperation("Could not derive file name from '%s'" % 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)
    +        # Ensure that name can be treated as a pure posix path, i.e. Unix
    +        # style (with forward slashes).
    +        path = pathlib.PurePosixPath(str(name).replace("\\", "/"))
             if path.is_absolute() or ".." in path.parts:
                 raise SuspiciousFileOperation(
                     "Detected path traversal attempt in '%s'" % name
    
  • docs/releases/4.2.14.txt+12 0 modified
    @@ -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
     allowed remote attackers to enumerate users via a timing attack involving login
     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.
    
  • tests/file_storage/test_base.py+70 0 added
    @@ -0,0 +1,70 @@
    +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")
    
  • tests/file_storage/tests.py+3 8 modified
    @@ -342,22 +342,17 @@ def test_file_save_with_path(self):
     
             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(
             symlinks_supported(), "Must be able to symlink to run this test."
         )
         def test_file_save_broken_symlink(self):
             """A new path is created on save when a broken symlink is supplied."""
             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)
             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)
     
         def test_save_doesnt_close(self):
    
  • tests/file_uploads/tests.py+1 1 modified
    @@ -826,7 +826,7 @@ def test_not_a_directory(self):
             default_storage.delete(UPLOAD_TO)
             # Create a file with the upload directory name
             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)
             msg = "%s exists and is not a directory." % UPLOAD_TO
             with self.assertRaisesMessage(FileExistsError, msg):
    
9f4f63e9ebb7

[5.0.x] Fixed CVE-2024-39330 -- Added extra file name validation in Storage's save method.

https://github.com/django/djangoNataliaMar 20, 2024via ghsa
7 files changed · +114 13
  • django/core/files/storage/base.py+11 0 modified
    @@ -34,7 +34,18 @@ def save(self, name, content, max_length=None):
             if not hasattr(content, "chunks"):
                 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)
    +        # 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)
             # Ensure that the name returned from the storage system is still valid.
             validate_file_name(name, allow_relative_path=True)
    
  • django/core/files/utils.py+3 4 modified
    @@ -10,10 +10,9 @@ def validate_file_name(name, allow_relative_path=False):
             raise SuspiciousFileOperation("Could not derive file name from '%s'" % 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)
    +        # Ensure that name can be treated as a pure posix path, i.e. Unix
    +        # style (with forward slashes).
    +        path = pathlib.PurePosixPath(str(name).replace("\\", "/"))
             if path.is_absolute() or ".." in path.parts:
                 raise SuspiciousFileOperation(
                     "Detected path traversal attempt in '%s'" % name
    
  • docs/releases/4.2.14.txt+12 0 modified
    @@ -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
     allowed remote attackers to enumerate users via a timing attack involving login
     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.
    
  • docs/releases/5.0.7.txt+12 0 modified
    @@ -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
     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
     ========
     
    
  • tests/file_storage/test_base.py+72 0 added
    @@ -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")
    
  • tests/file_storage/tests.py+3 8 modified
    @@ -342,22 +342,17 @@ def test_file_save_with_path(self):
     
             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(
             symlinks_supported(), "Must be able to symlink to run this test."
         )
         def test_file_save_broken_symlink(self):
             """A new path is created on save when a broken symlink is supplied."""
             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)
             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)
     
         def test_save_doesnt_close(self):
    
  • tests/file_uploads/tests.py+1 1 modified
    @@ -828,7 +828,7 @@ def test_not_a_directory(self):
             default_storage.delete(UPLOAD_TO)
             # Create a file with the upload directory name
             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)
             msg = "%s exists and is not a directory." % UPLOAD_TO
             with self.assertRaisesMessage(FileExistsError, msg):
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

11

News mentions

0

No linked articles in our index yet.