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.
| Package | Affected versions | Patched versions |
|---|---|---|
DjangoPyPI | >= 5.0, < 5.0.7 | 5.0.7 |
DjangoPyPI | >= 4.2, < 4.2.14 | 4.2.14 |
Affected products
31- Django/Djangodescription
- osv-coords30 versionspkg:apk/chainguard/py3.10-djangopkg:apk/chainguard/py3.10-django-binpkg:apk/chainguard/py3.11-djangopkg:apk/chainguard/py3.11-django-binpkg:apk/chainguard/py3.12-djangopkg:apk/chainguard/py3.12-django-binpkg:apk/chainguard/py3.13-djangopkg:apk/chainguard/py3.13-django-binpkg:apk/chainguard/py3-djangopkg:apk/chainguard/py3-supported-djangopkg:apk/wolfi/py3.10-djangopkg:apk/wolfi/py3.10-django-binpkg:apk/wolfi/py3.11-djangopkg:apk/wolfi/py3.11-django-binpkg:apk/wolfi/py3.12-djangopkg:apk/wolfi/py3.12-django-binpkg:apk/wolfi/py3.13-djangopkg:apk/wolfi/py3.13-django-binpkg:apk/wolfi/py3-djangopkg:apk/wolfi/py3-supported-djangopkg:bitnami/djangopkg:pypi/djangopkg:rpm/opensuse/python-Django4&distro=openSUSE%20Tumbleweedpkg:rpm/opensuse/python-Django6&distro=openSUSE%20Tumbleweedpkg:rpm/opensuse/python-Django&distro=openSUSE%20Leap%2015.5pkg:rpm/opensuse/python-Django&distro=openSUSE%20Leap%2015.6pkg:rpm/opensuse/python-Django&distro=openSUSE%20Tumbleweedpkg:rpm/opensuse/python-django-storages&distro=openSUSE%20Tumbleweedpkg:rpm/suse/python-Django&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Package%20Hub%2015%20SP6pkg:rpm/suse/python-Django&distro=SUSE%20Package%20Hub%2015%20SP5
< 5.0.7-r0+ 29 more
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: < 5.0.7-r0
- (no CPE)range: >= 4.2.0, < 4.2.14
- (no CPE)range: >= 5.0, < 5.0.7
- (no CPE)range: < 4.2.14-1.1
- (no CPE)range: < 6.0-1.1
- (no CPE)range: < 2.0.7-150000.1.20.1
- (no CPE)range: < 4.2.11-150600.3.3.1
- (no CPE)range: < 5.0.7-2.1
- (no CPE)range: < 1.14.6-1.1
- (no CPE)range: < 4.2.11-150600.3.3.1
- (no CPE)range: < 2.2.28-bp155.7.12.1
Patches
498cf264c9cb0deec9b933ee82b00edc0151a[4.2.x] Fixed CVE-2024-39330 -- Added extra file name validation in Storage's save method.
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.
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- github.com/advisories/GHSA-9jmf-237g-qf46ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-39330ghsaADVISORY
- docs.djangoproject.com/en/dev/releases/securityghsaWEB
- github.com/django/django/commit/2b00edc0151a660d1eb86da4059904a0fc4e095eghsaWEB
- github.com/django/django/commit/9f4f63e9ebb7bf6cb9547ee4e2526b9b96703270ghsaWEB
- github.com/pypa/advisory-database/tree/main/vulns/django/PYSEC-2024-58.yamlghsaWEB
- groups.google.com/forum/ghsaWEB
- security.netapp.com/advisory/ntap-20240808-0005ghsaWEB
- www.djangoproject.com/weblog/2024/jul/09/security-releasesghsaWEB
- docs.djangoproject.com/en/dev/releases/security/mitre
- www.djangoproject.com/weblog/2024/jul/09/security-releases/mitre
News mentions
0No linked articles in our index yet.