VYPR
High severityNVD Advisory· Published May 5, 2021· Updated Aug 3, 2024

CVE-2021-31542

CVE-2021-31542

Description

In Django 2.2 before 2.2.21, 3.1 before 3.1.9, and 3.2 before 3.2.1, MultiPartParser, UploadedFile, and FieldFile allowed directory traversal via uploaded files with suitably crafted file names.

AI Insight

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

In Django 2.2-2.2.20, 3.1-3.1.8, 3.2-3.2.0, crafted filenames allowed directory traversal via MultiPartParser, UploadedFile, and FieldFile.

Vulnerability

In Django versions 2.2 before 2.2.21, 3.1 before 3.1.9, and 3.2 before 3.2.1, the MultiPartParser, UploadedFile, and FieldFile components did not properly sanitize uploaded file names containing path traversal sequences. An attacker could craft a filename with .. to overwrite files outside the intended upload directory. The affected code paths are in django/core/files/uploadedfile.py and django/core/files/storage.py [1][2][3].

Exploitation

An attacker with the ability to upload files to a Django application (e.g., via a form with a FileField or ImageField) could supply a filename containing ../ or similar traversal patterns. No authentication is required if the upload endpoint is public; the attacker only needs network access to the upload functionality. The filename is processed without validation, allowing directory traversal [2][3].

Impact

Successful exploitation allows an attacker to write or overwrite files anywhere on the filesystem that the web server process has write permissions. This could lead to arbitrary code execution if, for example, a malicious file is written to a directory from which it can be executed (e.g., static files or Python modules). The severity is high due to potential for remote code execution [1][4].

Mitigation

The fix was released in Django 2.2.21, 3.1.9, and 3.2.1. The commits add pathlib.PurePath checks for .. parts and call validate_file_name to sanitize filenames [2][3]. Users should upgrade immediately. No workarounds are provided; upgrading is the only mitigation [1][4].

AI Insight generated on May 21, 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
>= 2.2, < 2.2.212.2.21
DjangoPyPI
>= 3.0, < 3.1.93.1.9
DjangoPyPI
>= 3.2, < 3.2.13.2.1

Affected products

164

Patches

3
04ac1624bdc2

[2.2.x] Fixed CVE-2021-31542 -- Tightened path & file name sanitation in file uploads.

https://github.com/django/djangoFlorian ApollonerApr 14, 2021via ghsa
12 files changed · +162 13
  • django/core/files/storage.py+7 0 modified
    @@ -1,11 +1,13 @@
     import os
    +import pathlib
     from datetime import datetime
     from urllib.parse import urljoin
     
     from django.conf import settings
     from django.core.exceptions import SuspiciousFileOperation
     from django.core.files import File, locks
     from django.core.files.move import file_move_safe
    +from django.core.files.utils import validate_file_name
     from django.core.signals import setting_changed
     from django.utils import timezone
     from django.utils._os import safe_join
    @@ -66,6 +68,9 @@ def get_available_name(self, name, max_length=None):
             available for new content to be written to.
             """
             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)
    +        validate_file_name(file_name)
             file_root, file_ext = os.path.splitext(file_name)
             # If the filename already exists, add an underscore and a random 7
             # character alphanumeric string (before the file extension, if one
    @@ -98,6 +103,8 @@ def generate_filename(self, filename):
             """
             # `filename` may include a path as returned by FileField.upload_to.
             dirname, filename = os.path.split(filename)
    +        if '..' in pathlib.PurePath(dirname).parts:
    +            raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dirname)
             return os.path.normpath(os.path.join(dirname, self.get_valid_name(filename)))
     
         def path(self, name):
    
  • django/core/files/uploadedfile.py+3 0 modified
    @@ -8,6 +8,7 @@
     from django.conf import settings
     from django.core.files import temp as tempfile
     from django.core.files.base import File
    +from django.core.files.utils import validate_file_name
     
     __all__ = ('UploadedFile', 'TemporaryUploadedFile', 'InMemoryUploadedFile',
                'SimpleUploadedFile')
    @@ -47,6 +48,8 @@ def _set_name(self, name):
                     ext = ext[:255]
                     name = name[:255 - len(ext)] + ext
     
    +            name = validate_file_name(name)
    +
             self._name = name
     
         name = property(_get_name, _set_name)
    
  • django/core/files/utils.py+16 0 modified
    @@ -1,3 +1,19 @@
    +import os
    +
    +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)
    +
    +    # Remove potentially dangerous names
    +    if name in {'', '.', '..'}:
    +        raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
    +
    +    return name
    +
    +
     class FileProxyMixin:
         """
         A mixin class used to forward file methods to an underlaying file
    
  • django/db/models/fields/files.py+2 0 modified
    @@ -6,6 +6,7 @@
     from django.core.files.base import File
     from django.core.files.images import ImageFile
     from django.core.files.storage import default_storage
    +from django.core.files.utils import validate_file_name
     from django.db.models import signals
     from django.db.models.fields import Field
     from django.utils.translation import gettext_lazy as _
    @@ -299,6 +300,7 @@ def generate_filename(self, instance, filename):
             Until the storage layer, all file paths are expected to be Unix style
             (with forward slashes).
             """
    +        filename = validate_file_name(filename)
             if callable(self.upload_to):
                 filename = self.upload_to(instance, filename)
             else:
    
  • django/http/multipartparser.py+20 6 modified
    @@ -7,7 +7,7 @@
     import base64
     import binascii
     import cgi
    -import os
    +import html
     from urllib.parse import unquote
     
     from django.conf import settings
    @@ -19,7 +19,6 @@
     )
     from django.utils.datastructures import MultiValueDict
     from django.utils.encoding import force_text
    -from django.utils.text import unescape_entities
     
     __all__ = ('MultiPartParser', 'MultiPartParserError', 'InputStreamExhausted')
     
    @@ -295,10 +294,25 @@ def handle_file_complete(self, old_field_name, counters):
                     break
     
         def sanitize_file_name(self, file_name):
    -        file_name = unescape_entities(file_name)
    -        # Cleanup Windows-style path separators.
    -        file_name = file_name[file_name.rfind('\\') + 1:].strip()
    -        return os.path.basename(file_name)
    +        """
    +        Sanitize the filename of an upload.
    +
    +        Remove all possible path separators, even though that might remove more
    +        than actually required by the target system. Filenames that could
    +        potentially cause problems (current/parent dir) are also discarded.
    +
    +        It should be noted that this function could still return a "filepath"
    +        like "C:some_file.txt" which is handled later on by the storage layer.
    +        So while this function does sanitize filenames to some extent, the
    +        resulting filename should still be considered as untrusted user input.
    +        """
    +        file_name = html.unescape(file_name)
    +        file_name = file_name.rsplit('/')[-1]
    +        file_name = file_name.rsplit('\\')[-1]
    +
    +        if file_name in {'', '.', '..'}:
    +            return None
    +        return file_name
     
         IE_sanitize = sanitize_file_name
     
    
  • django/utils/text.py+7 3 modified
    @@ -4,6 +4,7 @@
     from gzip import GzipFile
     from io import BytesIO
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.utils.functional import SimpleLazyObject, keep_lazy_text, lazy
     from django.utils.translation import gettext as _, gettext_lazy, pgettext
     
    @@ -216,7 +217,7 @@ def _truncate_html(self, length, truncate, text, truncate_len, words):
     
     
     @keep_lazy_text
    -def get_valid_filename(s):
    +def get_valid_filename(name):
         """
         Return the given string converted to a string that can be used for a clean
         filename. Remove leading and trailing spaces; convert other spaces to
    @@ -225,8 +226,11 @@ def get_valid_filename(s):
         >>> get_valid_filename("john's portrait in 2004.jpg")
         'johns_portrait_in_2004.jpg'
         """
    -    s = str(s).strip().replace(' ', '_')
    -    return re.sub(r'(?u)[^-\w.]', '', s)
    +    s = str(name).strip().replace(' ', '_')
    +    s = re.sub(r'(?u)[^-\w.]', '', s)
    +    if s in {'', '.', '..'}:
    +        raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
    +    return s
     
     
     @keep_lazy_text
    
  • docs/releases/2.2.21.txt+17 0 added
    @@ -0,0 +1,17 @@
    +===========================
    +Django 2.2.21 release notes
    +===========================
    +
    +*May 4, 2021*
    +
    +Django 2.2.21 fixes a security issue in 2.2.20.
    +
    +CVE-2021-31542: Potential directory-traversal via uploaded files
    +================================================================
    +
    +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed
    +directory-traversal via uploaded files with suitably crafted file names.
    +
    +In order to mitigate this risk, stricter basename and path sanitation is now
    +applied. Specifically, empty file names and paths with dot segments will be
    +rejected.
    
  • docs/releases/index.txt+1 0 modified
    @@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases.
     .. toctree::
        :maxdepth: 1
     
    +   2.2.21
        2.2.20
        2.2.19
        2.2.18
    
  • tests/file_storage/test_generate_filename.py+40 1 modified
    @@ -1,7 +1,8 @@
     import os
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.core.files.base import ContentFile
    -from django.core.files.storage import Storage
    +from django.core.files.storage import FileSystemStorage, Storage
     from django.db.models import FileField
     from django.test import SimpleTestCase
     
    @@ -36,6 +37,44 @@ def generate_filename(self, filename):
     
     
     class GenerateFilenameStorageTests(SimpleTestCase):
    +    def test_storage_dangerous_paths(self):
    +        candidates = [
    +            ('/tmp/..', '..'),
    +            ('/tmp/.', '.'),
    +            ('', ''),
    +        ]
    +        s = FileSystemStorage()
    +        msg = "Could not derive file name from '%s'"
    +        for file_name, base_name in candidates:
    +            with self.subTest(file_name=file_name):
    +                with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name):
    +                    s.get_available_name(file_name)
    +                with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name):
    +                    s.generate_filename(file_name)
    +
    +    def test_storage_dangerous_paths_dir_name(self):
    +        file_name = '/tmp/../path'
    +        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)
    +
    +    def test_filefield_dangerous_filename(self):
    +        candidates = ['..', '.', '', '???', '$.$.$']
    +        f = FileField(upload_to='some/folder/')
    +        msg = "Could not derive file name from '%s'"
    +        for file_name in candidates:
    +            with self.subTest(file_name=file_name):
    +                with self.assertRaisesMessage(SuspiciousFileOperation, msg % file_name):
    +                    f.generate_filename(None, file_name)
    +
    +    def test_filefield_dangerous_filename_dir(self):
    +        f = FileField(upload_to='some/folder/')
    +        msg = "File name '/tmp/path' includes path elements"
    +        with self.assertRaisesMessage(SuspiciousFileOperation, msg):
    +            f.generate_filename(None, '/tmp/path')
     
         def test_filefield_generate_filename(self):
             f = FileField(upload_to='some/folder/')
    
  • tests/file_uploads/tests.py+37 1 modified
    @@ -8,8 +8,9 @@
     from io import BytesIO, StringIO
     from urllib.parse import quote
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.core.files import temp as tempfile
    -from django.core.files.uploadedfile import SimpleUploadedFile
    +from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
     from django.http.multipartparser import (
         MultiPartParser, MultiPartParserError, parse_header,
     )
    @@ -37,6 +38,16 @@
         '..&#x2F;hax0rd.txt',       # HTML entities.
     ]
     
    +CANDIDATE_INVALID_FILE_NAMES = [
    +    '/tmp/',        # Directory, *nix-style.
    +    'c:\\tmp\\',    # Directory, win-style.
    +    '/tmp/.',       # Directory dot, *nix-style.
    +    'c:\\tmp\\.',   # Directory dot, *nix-style.
    +    '/tmp/..',      # Parent directory, *nix-style.
    +    'c:\\tmp\\..',  # Parent directory, win-style.
    +    '',             # Empty filename.
    +]
    +
     
     @override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE=[])
     class FileUploadTests(TestCase):
    @@ -52,6 +63,22 @@ def tearDownClass(cls):
             shutil.rmtree(MEDIA_ROOT)
             super().tearDownClass()
     
    +    def test_upload_name_is_validated(self):
    +        candidates = [
    +            '/tmp/',
    +            '/tmp/..',
    +            '/tmp/.',
    +        ]
    +        if sys.platform == 'win32':
    +            candidates.extend([
    +                'c:\\tmp\\',
    +                'c:\\tmp\\..',
    +                'c:\\tmp\\.',
    +            ])
    +        for file_name in candidates:
    +            with self.subTest(file_name=file_name):
    +                self.assertRaises(SuspiciousFileOperation, UploadedFile, name=file_name)
    +
         def test_simple_upload(self):
             with open(__file__, 'rb') as fp:
                 post_data = {
    @@ -631,6 +658,15 @@ def test_sanitize_file_name(self):
                 with self.subTest(file_name=file_name):
                     self.assertEqual(parser.sanitize_file_name(file_name), 'hax0rd.txt')
     
    +    def test_sanitize_invalid_file_name(self):
    +        parser = MultiPartParser({
    +            'CONTENT_TYPE': 'multipart/form-data; boundary=_foo',
    +            'CONTENT_LENGTH': '1',
    +        }, StringIO('x'), [], 'utf-8')
    +        for file_name in CANDIDATE_INVALID_FILE_NAMES:
    +            with self.subTest(file_name=file_name):
    +                self.assertIsNone(parser.sanitize_file_name(file_name))
    +
         def test_rfc2231_parsing(self):
             test_data = (
                 (b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A",
    
  • tests/forms_tests/field_tests/test_filefield.py+4 2 modified
    @@ -20,10 +20,12 @@ def test_filefield_1(self):
                 f.clean(None, '')
             self.assertEqual('files/test2.pdf', f.clean(None, 'files/test2.pdf'))
             no_file_msg = "'No file was submitted. Check the encoding type on the form.'"
    +        file = SimpleUploadedFile(None, b'')
    +        file._name = ''
             with self.assertRaisesMessage(ValidationError, no_file_msg):
    -            f.clean(SimpleUploadedFile('', b''))
    +            f.clean(file)
             with self.assertRaisesMessage(ValidationError, no_file_msg):
    -            f.clean(SimpleUploadedFile('', b''), '')
    +            f.clean(file, '')
             self.assertEqual('files/test3.pdf', f.clean(None, 'files/test3.pdf'))
             with self.assertRaisesMessage(ValidationError, no_file_msg):
                 f.clean('some content that is not a file')
    
  • tests/utils_tests/test_text.py+8 0 modified
    @@ -1,6 +1,7 @@
     import json
     import sys
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.test import SimpleTestCase
     from django.utils import text
     from django.utils.functional import lazystr
    @@ -229,6 +230,13 @@ def test_get_valid_filename(self):
             filename = "^&'@{}[],$=!-#()%+~_123.txt"
             self.assertEqual(text.get_valid_filename(filename), "-_123.txt")
             self.assertEqual(text.get_valid_filename(lazystr(filename)), "-_123.txt")
    +        msg = "Could not derive file name from '???'"
    +        with self.assertRaisesMessage(SuspiciousFileOperation, msg):
    +            text.get_valid_filename('???')
    +        # After sanitizing this would yield '..'.
    +        msg = "Could not derive file name from '$.$.$'"
    +        with self.assertRaisesMessage(SuspiciousFileOperation, msg):
    +            text.get_valid_filename('$.$.$')
     
         def test_compress_sequence(self):
             data = [{'key': i} for i in range(10)]
    
c98f446c1885

[3.2.x] Fixed CVE-2021-31542 -- Tightened path & file name sanitation in file uploads.

https://github.com/django/djangoFlorian ApollonerApr 14, 2021via ghsa
14 files changed · +190 13
  • django/core/files/storage.py+7 0 modified
    @@ -1,11 +1,13 @@
     import os
    +import pathlib
     from datetime import datetime
     from urllib.parse import urljoin
     
     from django.conf import settings
     from django.core.exceptions import SuspiciousFileOperation
     from django.core.files import File, locks
     from django.core.files.move import file_move_safe
    +from django.core.files.utils import validate_file_name
     from django.core.signals import setting_changed
     from django.utils import timezone
     from django.utils._os import safe_join
    @@ -74,6 +76,9 @@ def get_available_name(self, name, max_length=None):
             available for new content to be written to.
             """
             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)
    +        validate_file_name(file_name)
             file_root, file_ext = os.path.splitext(file_name)
             # If the filename already exists, generate an alternative filename
             # until it doesn't exist.
    @@ -105,6 +110,8 @@ def generate_filename(self, filename):
             """
             # `filename` may include a path as returned by FileField.upload_to.
             dirname, filename = os.path.split(filename)
    +        if '..' in pathlib.PurePath(dirname).parts:
    +            raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dirname)
             return os.path.normpath(os.path.join(dirname, self.get_valid_name(filename)))
     
         def path(self, name):
    
  • django/core/files/uploadedfile.py+3 0 modified
    @@ -8,6 +8,7 @@
     from django.conf import settings
     from django.core.files import temp as tempfile
     from django.core.files.base import File
    +from django.core.files.utils import validate_file_name
     
     __all__ = ('UploadedFile', 'TemporaryUploadedFile', 'InMemoryUploadedFile',
                'SimpleUploadedFile')
    @@ -47,6 +48,8 @@ def _set_name(self, name):
                     ext = ext[:255]
                     name = name[:255 - len(ext)] + ext
     
    +            name = validate_file_name(name)
    +
             self._name = name
     
         name = property(_get_name, _set_name)
    
  • django/core/files/utils.py+16 0 modified
    @@ -1,3 +1,19 @@
    +import os
    +
    +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)
    +
    +    # Remove potentially dangerous names
    +    if name in {'', '.', '..'}:
    +        raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
    +
    +    return name
    +
    +
     class FileProxyMixin:
         """
         A mixin class used to forward file methods to an underlaying file
    
  • django/db/models/fields/files.py+2 0 modified
    @@ -6,6 +6,7 @@
     from django.core.files.base import File
     from django.core.files.images import ImageFile
     from django.core.files.storage import Storage, default_storage
    +from django.core.files.utils import validate_file_name
     from django.db.models import signals
     from django.db.models.fields import Field
     from django.db.models.query_utils import DeferredAttribute
    @@ -312,6 +313,7 @@ def generate_filename(self, instance, filename):
             Until the storage layer, all file paths are expected to be Unix style
             (with forward slashes).
             """
    +        filename = validate_file_name(filename)
             if callable(self.upload_to):
                 filename = self.upload_to(instance, filename)
             else:
    
  • django/http/multipartparser.py+18 4 modified
    @@ -9,7 +9,6 @@
     import cgi
     import collections
     import html
    -import os
     from urllib.parse import unquote
     
     from django.conf import settings
    @@ -306,10 +305,25 @@ def handle_file_complete(self, old_field_name, counters):
                     break
     
         def sanitize_file_name(self, file_name):
    +        """
    +        Sanitize the filename of an upload.
    +
    +        Remove all possible path separators, even though that might remove more
    +        than actually required by the target system. Filenames that could
    +        potentially cause problems (current/parent dir) are also discarded.
    +
    +        It should be noted that this function could still return a "filepath"
    +        like "C:some_file.txt" which is handled later on by the storage layer.
    +        So while this function does sanitize filenames to some extent, the
    +        resulting filename should still be considered as untrusted user input.
    +        """
             file_name = html.unescape(file_name)
    -        # Cleanup Windows-style path separators.
    -        file_name = file_name[file_name.rfind('\\') + 1:].strip()
    -        return os.path.basename(file_name)
    +        file_name = file_name.rsplit('/')[-1]
    +        file_name = file_name.rsplit('\\')[-1]
    +
    +        if file_name in {'', '.', '..'}:
    +            return None
    +        return file_name
     
         IE_sanitize = sanitize_file_name
     
    
  • django/utils/text.py+7 3 modified
    @@ -5,6 +5,7 @@
     from gzip import GzipFile
     from io import BytesIO
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.utils.deprecation import RemovedInDjango40Warning
     from django.utils.functional import SimpleLazyObject, keep_lazy_text, lazy
     from django.utils.regex_helper import _lazy_re_compile
    @@ -219,7 +220,7 @@ def _truncate_html(self, length, truncate, text, truncate_len, words):
     
     
     @keep_lazy_text
    -def get_valid_filename(s):
    +def get_valid_filename(name):
         """
         Return the given string converted to a string that can be used for a clean
         filename. Remove leading and trailing spaces; convert other spaces to
    @@ -228,8 +229,11 @@ def get_valid_filename(s):
         >>> get_valid_filename("john's portrait in 2004.jpg")
         'johns_portrait_in_2004.jpg'
         """
    -    s = str(s).strip().replace(' ', '_')
    -    return re.sub(r'(?u)[^-\w.]', '', s)
    +    s = str(name).strip().replace(' ', '_')
    +    s = re.sub(r'(?u)[^-\w.]', '', s)
    +    if s in {'', '.', '..'}:
    +        raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
    +    return s
     
     
     @keep_lazy_text
    
  • docs/releases/2.2.21.txt+17 0 added
    @@ -0,0 +1,17 @@
    +===========================
    +Django 2.2.21 release notes
    +===========================
    +
    +*May 4, 2021*
    +
    +Django 2.2.21 fixes a security issue in 2.2.20.
    +
    +CVE-2021-31542: Potential directory-traversal via uploaded files
    +================================================================
    +
    +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed
    +directory-traversal via uploaded files with suitably crafted file names.
    +
    +In order to mitigate this risk, stricter basename and path sanitation is now
    +applied. Specifically, empty file names and paths with dot segments will be
    +rejected.
    
  • docs/releases/3.1.9.txt+17 0 added
    @@ -0,0 +1,17 @@
    +==========================
    +Django 3.1.9 release notes
    +==========================
    +
    +*May 4, 2021*
    +
    +Django 3.1.9 fixes a security issue in 3.1.8.
    +
    +CVE-2021-31542: Potential directory-traversal via uploaded files
    +================================================================
    +
    +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed
    +directory-traversal via uploaded files with suitably crafted file names.
    +
    +In order to mitigate this risk, stricter basename and path sanitation is now
    +applied. Specifically, empty file names and paths with dot segments will be
    +rejected.
    
  • docs/releases/3.2.1.txt+12 2 modified
    @@ -2,9 +2,19 @@
     Django 3.2.1 release notes
     ==========================
     
    -*Expected May 4, 2021*
    +*May 4, 2021*
     
    -Django 3.2.1 fixes several bugs in 3.2.
    +Django 3.2.1 fixes a security issue and several bugs in 3.2.
    +
    +CVE-2021-31542: Potential directory-traversal via uploaded files
    +================================================================
    +
    +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed
    +directory-traversal via uploaded files with suitably crafted file names.
    +
    +In order to mitigate this risk, stricter basename and path sanitation is now
    +applied. Specifically, empty file names and paths with dot segments will be
    +rejected.
     
     Bugfixes
     ========
    
  • docs/releases/index.txt+2 0 modified
    @@ -33,6 +33,7 @@ versions of the documentation contain the release notes for any later releases.
     .. toctree::
        :maxdepth: 1
     
    +   3.1.9
        3.1.8
        3.1.7
        3.1.6
    @@ -69,6 +70,7 @@ versions of the documentation contain the release notes for any later releases.
     .. toctree::
        :maxdepth: 1
     
    +   2.2.21
        2.2.20
        2.2.19
        2.2.18
    
  • tests/file_storage/test_generate_filename.py+40 1 modified
    @@ -1,7 +1,8 @@
     import os
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.core.files.base import ContentFile
    -from django.core.files.storage import Storage
    +from django.core.files.storage import FileSystemStorage, Storage
     from django.db.models import FileField
     from django.test import SimpleTestCase
     
    @@ -36,6 +37,44 @@ def generate_filename(self, filename):
     
     
     class GenerateFilenameStorageTests(SimpleTestCase):
    +    def test_storage_dangerous_paths(self):
    +        candidates = [
    +            ('/tmp/..', '..'),
    +            ('/tmp/.', '.'),
    +            ('', ''),
    +        ]
    +        s = FileSystemStorage()
    +        msg = "Could not derive file name from '%s'"
    +        for file_name, base_name in candidates:
    +            with self.subTest(file_name=file_name):
    +                with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name):
    +                    s.get_available_name(file_name)
    +                with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name):
    +                    s.generate_filename(file_name)
    +
    +    def test_storage_dangerous_paths_dir_name(self):
    +        file_name = '/tmp/../path'
    +        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)
    +
    +    def test_filefield_dangerous_filename(self):
    +        candidates = ['..', '.', '', '???', '$.$.$']
    +        f = FileField(upload_to='some/folder/')
    +        msg = "Could not derive file name from '%s'"
    +        for file_name in candidates:
    +            with self.subTest(file_name=file_name):
    +                with self.assertRaisesMessage(SuspiciousFileOperation, msg % file_name):
    +                    f.generate_filename(None, file_name)
    +
    +    def test_filefield_dangerous_filename_dir(self):
    +        f = FileField(upload_to='some/folder/')
    +        msg = "File name '/tmp/path' includes path elements"
    +        with self.assertRaisesMessage(SuspiciousFileOperation, msg):
    +            f.generate_filename(None, '/tmp/path')
     
         def test_filefield_generate_filename(self):
             f = FileField(upload_to='some/folder/')
    
  • tests/file_uploads/tests.py+37 1 modified
    @@ -9,8 +9,9 @@
     from unittest import mock
     from urllib.parse import quote
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.core.files import temp as tempfile
    -from django.core.files.uploadedfile import SimpleUploadedFile
    +from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
     from django.http.multipartparser import (
         FILE, MultiPartParser, MultiPartParserError, Parser, parse_header,
     )
    @@ -39,6 +40,16 @@
         '..&sol;hax0rd.txt',        # HTML entities.
     ]
     
    +CANDIDATE_INVALID_FILE_NAMES = [
    +    '/tmp/',        # Directory, *nix-style.
    +    'c:\\tmp\\',    # Directory, win-style.
    +    '/tmp/.',       # Directory dot, *nix-style.
    +    'c:\\tmp\\.',   # Directory dot, *nix-style.
    +    '/tmp/..',      # Parent directory, *nix-style.
    +    'c:\\tmp\\..',  # Parent directory, win-style.
    +    '',             # Empty filename.
    +]
    +
     
     @override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE=[])
     class FileUploadTests(TestCase):
    @@ -53,6 +64,22 @@ def tearDownClass(cls):
             shutil.rmtree(MEDIA_ROOT)
             super().tearDownClass()
     
    +    def test_upload_name_is_validated(self):
    +        candidates = [
    +            '/tmp/',
    +            '/tmp/..',
    +            '/tmp/.',
    +        ]
    +        if sys.platform == 'win32':
    +            candidates.extend([
    +                'c:\\tmp\\',
    +                'c:\\tmp\\..',
    +                'c:\\tmp\\.',
    +            ])
    +        for file_name in candidates:
    +            with self.subTest(file_name=file_name):
    +                self.assertRaises(SuspiciousFileOperation, UploadedFile, name=file_name)
    +
         def test_simple_upload(self):
             with open(__file__, 'rb') as fp:
                 post_data = {
    @@ -718,6 +745,15 @@ def test_sanitize_file_name(self):
                 with self.subTest(file_name=file_name):
                     self.assertEqual(parser.sanitize_file_name(file_name), 'hax0rd.txt')
     
    +    def test_sanitize_invalid_file_name(self):
    +        parser = MultiPartParser({
    +            'CONTENT_TYPE': 'multipart/form-data; boundary=_foo',
    +            'CONTENT_LENGTH': '1',
    +        }, StringIO('x'), [], 'utf-8')
    +        for file_name in CANDIDATE_INVALID_FILE_NAMES:
    +            with self.subTest(file_name=file_name):
    +                self.assertIsNone(parser.sanitize_file_name(file_name))
    +
         def test_rfc2231_parsing(self):
             test_data = (
                 (b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A",
    
  • tests/forms_tests/field_tests/test_filefield.py+4 2 modified
    @@ -21,10 +21,12 @@ def test_filefield_1(self):
                 f.clean(None, '')
             self.assertEqual('files/test2.pdf', f.clean(None, 'files/test2.pdf'))
             no_file_msg = "'No file was submitted. Check the encoding type on the form.'"
    +        file = SimpleUploadedFile(None, b'')
    +        file._name = ''
             with self.assertRaisesMessage(ValidationError, no_file_msg):
    -            f.clean(SimpleUploadedFile('', b''))
    +            f.clean(file)
             with self.assertRaisesMessage(ValidationError, no_file_msg):
    -            f.clean(SimpleUploadedFile('', b''), '')
    +            f.clean(file, '')
             self.assertEqual('files/test3.pdf', f.clean(None, 'files/test3.pdf'))
             with self.assertRaisesMessage(ValidationError, no_file_msg):
                 f.clean('some content that is not a file')
    
  • tests/utils_tests/test_text.py+8 0 modified
    @@ -1,6 +1,7 @@
     import json
     import sys
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.test import SimpleTestCase, ignore_warnings
     from django.utils import text
     from django.utils.deprecation import RemovedInDjango40Warning
    @@ -255,6 +256,13 @@ def test_get_valid_filename(self):
             filename = "^&'@{}[],$=!-#()%+~_123.txt"
             self.assertEqual(text.get_valid_filename(filename), "-_123.txt")
             self.assertEqual(text.get_valid_filename(lazystr(filename)), "-_123.txt")
    +        msg = "Could not derive file name from '???'"
    +        with self.assertRaisesMessage(SuspiciousFileOperation, msg):
    +            text.get_valid_filename('???')
    +        # After sanitizing this would yield '..'.
    +        msg = "Could not derive file name from '$.$.$'"
    +        with self.assertRaisesMessage(SuspiciousFileOperation, msg):
    +            text.get_valid_filename('$.$.$')
     
         def test_compress_sequence(self):
             data = [{'key': i} for i in range(10)]
    
25d84d64122c

[3.1.x] Fixed CVE-2021-31542 -- Tightened path & file name sanitation in file uploads.

https://github.com/django/djangoFlorian ApollonerApr 14, 2021via ghsa
13 files changed · +178 11
  • django/core/files/storage.py+7 0 modified
    @@ -1,11 +1,13 @@
     import os
    +import pathlib
     from datetime import datetime
     from urllib.parse import urljoin
     
     from django.conf import settings
     from django.core.exceptions import SuspiciousFileOperation
     from django.core.files import File, locks
     from django.core.files.move import file_move_safe
    +from django.core.files.utils import validate_file_name
     from django.core.signals import setting_changed
     from django.utils import timezone
     from django.utils._os import safe_join
    @@ -74,6 +76,9 @@ def get_available_name(self, name, max_length=None):
             available for new content to be written to.
             """
             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)
    +        validate_file_name(file_name)
             file_root, file_ext = os.path.splitext(file_name)
             # If the filename already exists, generate an alternative filename
             # until it doesn't exist.
    @@ -105,6 +110,8 @@ def generate_filename(self, filename):
             """
             # `filename` may include a path as returned by FileField.upload_to.
             dirname, filename = os.path.split(filename)
    +        if '..' in pathlib.PurePath(dirname).parts:
    +            raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dirname)
             return os.path.normpath(os.path.join(dirname, self.get_valid_name(filename)))
     
         def path(self, name):
    
  • django/core/files/uploadedfile.py+3 0 modified
    @@ -8,6 +8,7 @@
     from django.conf import settings
     from django.core.files import temp as tempfile
     from django.core.files.base import File
    +from django.core.files.utils import validate_file_name
     
     __all__ = ('UploadedFile', 'TemporaryUploadedFile', 'InMemoryUploadedFile',
                'SimpleUploadedFile')
    @@ -47,6 +48,8 @@ def _set_name(self, name):
                     ext = ext[:255]
                     name = name[:255 - len(ext)] + ext
     
    +            name = validate_file_name(name)
    +
             self._name = name
     
         name = property(_get_name, _set_name)
    
  • django/core/files/utils.py+16 0 modified
    @@ -1,3 +1,19 @@
    +import os
    +
    +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)
    +
    +    # Remove potentially dangerous names
    +    if name in {'', '.', '..'}:
    +        raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
    +
    +    return name
    +
    +
     class FileProxyMixin:
         """
         A mixin class used to forward file methods to an underlaying file
    
  • django/db/models/fields/files.py+2 0 modified
    @@ -6,6 +6,7 @@
     from django.core.files.base import File
     from django.core.files.images import ImageFile
     from django.core.files.storage import Storage, default_storage
    +from django.core.files.utils import validate_file_name
     from django.db.models import signals
     from django.db.models.fields import Field
     from django.utils.translation import gettext_lazy as _
    @@ -318,6 +319,7 @@ def generate_filename(self, instance, filename):
             Until the storage layer, all file paths are expected to be Unix style
             (with forward slashes).
             """
    +        filename = validate_file_name(filename)
             if callable(self.upload_to):
                 filename = self.upload_to(instance, filename)
             else:
    
  • django/http/multipartparser.py+18 4 modified
    @@ -9,7 +9,6 @@
     import cgi
     import collections
     import html
    -import os
     from urllib.parse import unquote
     
     from django.conf import settings
    @@ -299,10 +298,25 @@ def handle_file_complete(self, old_field_name, counters):
                     break
     
         def sanitize_file_name(self, file_name):
    +        """
    +        Sanitize the filename of an upload.
    +
    +        Remove all possible path separators, even though that might remove more
    +        than actually required by the target system. Filenames that could
    +        potentially cause problems (current/parent dir) are also discarded.
    +
    +        It should be noted that this function could still return a "filepath"
    +        like "C:some_file.txt" which is handled later on by the storage layer.
    +        So while this function does sanitize filenames to some extent, the
    +        resulting filename should still be considered as untrusted user input.
    +        """
             file_name = html.unescape(file_name)
    -        # Cleanup Windows-style path separators.
    -        file_name = file_name[file_name.rfind('\\') + 1:].strip()
    -        return os.path.basename(file_name)
    +        file_name = file_name.rsplit('/')[-1]
    +        file_name = file_name.rsplit('\\')[-1]
    +
    +        if file_name in {'', '.', '..'}:
    +            return None
    +        return file_name
     
         IE_sanitize = sanitize_file_name
     
    
  • django/utils/text.py+7 3 modified
    @@ -5,6 +5,7 @@
     from gzip import GzipFile
     from io import BytesIO
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.utils.deprecation import RemovedInDjango40Warning
     from django.utils.functional import SimpleLazyObject, keep_lazy_text, lazy
     from django.utils.regex_helper import _lazy_re_compile
    @@ -219,7 +220,7 @@ def _truncate_html(self, length, truncate, text, truncate_len, words):
     
     
     @keep_lazy_text
    -def get_valid_filename(s):
    +def get_valid_filename(name):
         """
         Return the given string converted to a string that can be used for a clean
         filename. Remove leading and trailing spaces; convert other spaces to
    @@ -228,8 +229,11 @@ def get_valid_filename(s):
         >>> get_valid_filename("john's portrait in 2004.jpg")
         'johns_portrait_in_2004.jpg'
         """
    -    s = str(s).strip().replace(' ', '_')
    -    return re.sub(r'(?u)[^-\w.]', '', s)
    +    s = str(name).strip().replace(' ', '_')
    +    s = re.sub(r'(?u)[^-\w.]', '', s)
    +    if s in {'', '.', '..'}:
    +        raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
    +    return s
     
     
     @keep_lazy_text
    
  • docs/releases/2.2.21.txt+17 0 added
    @@ -0,0 +1,17 @@
    +===========================
    +Django 2.2.21 release notes
    +===========================
    +
    +*May 4, 2021*
    +
    +Django 2.2.21 fixes a security issue in 2.2.20.
    +
    +CVE-2021-31542: Potential directory-traversal via uploaded files
    +================================================================
    +
    +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed
    +directory-traversal via uploaded files with suitably crafted file names.
    +
    +In order to mitigate this risk, stricter basename and path sanitation is now
    +applied. Specifically, empty file names and paths with dot segments will be
    +rejected.
    
  • docs/releases/3.1.9.txt+17 0 added
    @@ -0,0 +1,17 @@
    +==========================
    +Django 3.1.9 release notes
    +==========================
    +
    +*May 4, 2021*
    +
    +Django 3.1.9 fixes a security issue in 3.1.8.
    +
    +CVE-2021-31542: Potential directory-traversal via uploaded files
    +================================================================
    +
    +``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed
    +directory-traversal via uploaded files with suitably crafted file names.
    +
    +In order to mitigate this risk, stricter basename and path sanitation is now
    +applied. Specifically, empty file names and paths with dot segments will be
    +rejected.
    
  • docs/releases/index.txt+2 0 modified
    @@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases.
     .. toctree::
        :maxdepth: 1
     
    +   3.1.9
        3.1.8
        3.1.7
        3.1.6
    @@ -61,6 +62,7 @@ versions of the documentation contain the release notes for any later releases.
     .. toctree::
        :maxdepth: 1
     
    +   2.2.21
        2.2.20
        2.2.19
        2.2.18
    
  • tests/file_storage/test_generate_filename.py+40 1 modified
    @@ -1,7 +1,8 @@
     import os
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.core.files.base import ContentFile
    -from django.core.files.storage import Storage
    +from django.core.files.storage import FileSystemStorage, Storage
     from django.db.models import FileField
     from django.test import SimpleTestCase
     
    @@ -36,6 +37,44 @@ def generate_filename(self, filename):
     
     
     class GenerateFilenameStorageTests(SimpleTestCase):
    +    def test_storage_dangerous_paths(self):
    +        candidates = [
    +            ('/tmp/..', '..'),
    +            ('/tmp/.', '.'),
    +            ('', ''),
    +        ]
    +        s = FileSystemStorage()
    +        msg = "Could not derive file name from '%s'"
    +        for file_name, base_name in candidates:
    +            with self.subTest(file_name=file_name):
    +                with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name):
    +                    s.get_available_name(file_name)
    +                with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name):
    +                    s.generate_filename(file_name)
    +
    +    def test_storage_dangerous_paths_dir_name(self):
    +        file_name = '/tmp/../path'
    +        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)
    +
    +    def test_filefield_dangerous_filename(self):
    +        candidates = ['..', '.', '', '???', '$.$.$']
    +        f = FileField(upload_to='some/folder/')
    +        msg = "Could not derive file name from '%s'"
    +        for file_name in candidates:
    +            with self.subTest(file_name=file_name):
    +                with self.assertRaisesMessage(SuspiciousFileOperation, msg % file_name):
    +                    f.generate_filename(None, file_name)
    +
    +    def test_filefield_dangerous_filename_dir(self):
    +        f = FileField(upload_to='some/folder/')
    +        msg = "File name '/tmp/path' includes path elements"
    +        with self.assertRaisesMessage(SuspiciousFileOperation, msg):
    +            f.generate_filename(None, '/tmp/path')
     
         def test_filefield_generate_filename(self):
             f = FileField(upload_to='some/folder/')
    
  • tests/file_uploads/tests.py+37 1 modified
    @@ -8,8 +8,9 @@
     from io import BytesIO, StringIO
     from urllib.parse import quote
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.core.files import temp as tempfile
    -from django.core.files.uploadedfile import SimpleUploadedFile
    +from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
     from django.http.multipartparser import (
         MultiPartParser, MultiPartParserError, parse_header,
     )
    @@ -38,6 +39,16 @@
         '..&sol;hax0rd.txt',        # HTML entities.
     ]
     
    +CANDIDATE_INVALID_FILE_NAMES = [
    +    '/tmp/',        # Directory, *nix-style.
    +    'c:\\tmp\\',    # Directory, win-style.
    +    '/tmp/.',       # Directory dot, *nix-style.
    +    'c:\\tmp\\.',   # Directory dot, *nix-style.
    +    '/tmp/..',      # Parent directory, *nix-style.
    +    'c:\\tmp\\..',  # Parent directory, win-style.
    +    '',             # Empty filename.
    +]
    +
     
     @override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE=[])
     class FileUploadTests(TestCase):
    @@ -52,6 +63,22 @@ def tearDownClass(cls):
             shutil.rmtree(MEDIA_ROOT)
             super().tearDownClass()
     
    +    def test_upload_name_is_validated(self):
    +        candidates = [
    +            '/tmp/',
    +            '/tmp/..',
    +            '/tmp/.',
    +        ]
    +        if sys.platform == 'win32':
    +            candidates.extend([
    +                'c:\\tmp\\',
    +                'c:\\tmp\\..',
    +                'c:\\tmp\\.',
    +            ])
    +        for file_name in candidates:
    +            with self.subTest(file_name=file_name):
    +                self.assertRaises(SuspiciousFileOperation, UploadedFile, name=file_name)
    +
         def test_simple_upload(self):
             with open(__file__, 'rb') as fp:
                 post_data = {
    @@ -685,6 +712,15 @@ def test_sanitize_file_name(self):
                 with self.subTest(file_name=file_name):
                     self.assertEqual(parser.sanitize_file_name(file_name), 'hax0rd.txt')
     
    +    def test_sanitize_invalid_file_name(self):
    +        parser = MultiPartParser({
    +            'CONTENT_TYPE': 'multipart/form-data; boundary=_foo',
    +            'CONTENT_LENGTH': '1',
    +        }, StringIO('x'), [], 'utf-8')
    +        for file_name in CANDIDATE_INVALID_FILE_NAMES:
    +            with self.subTest(file_name=file_name):
    +                self.assertIsNone(parser.sanitize_file_name(file_name))
    +
         def test_rfc2231_parsing(self):
             test_data = (
                 (b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A",
    
  • tests/forms_tests/field_tests/test_filefield.py+4 2 modified
    @@ -21,10 +21,12 @@ def test_filefield_1(self):
                 f.clean(None, '')
             self.assertEqual('files/test2.pdf', f.clean(None, 'files/test2.pdf'))
             no_file_msg = "'No file was submitted. Check the encoding type on the form.'"
    +        file = SimpleUploadedFile(None, b'')
    +        file._name = ''
             with self.assertRaisesMessage(ValidationError, no_file_msg):
    -            f.clean(SimpleUploadedFile('', b''))
    +            f.clean(file)
             with self.assertRaisesMessage(ValidationError, no_file_msg):
    -            f.clean(SimpleUploadedFile('', b''), '')
    +            f.clean(file, '')
             self.assertEqual('files/test3.pdf', f.clean(None, 'files/test3.pdf'))
             with self.assertRaisesMessage(ValidationError, no_file_msg):
                 f.clean('some content that is not a file')
    
  • tests/utils_tests/test_text.py+8 0 modified
    @@ -1,6 +1,7 @@
     import json
     import sys
     
    +from django.core.exceptions import SuspiciousFileOperation
     from django.test import SimpleTestCase, ignore_warnings
     from django.utils import text
     from django.utils.deprecation import RemovedInDjango40Warning
    @@ -243,6 +244,13 @@ def test_get_valid_filename(self):
             filename = "^&'@{}[],$=!-#()%+~_123.txt"
             self.assertEqual(text.get_valid_filename(filename), "-_123.txt")
             self.assertEqual(text.get_valid_filename(lazystr(filename)), "-_123.txt")
    +        msg = "Could not derive file name from '???'"
    +        with self.assertRaisesMessage(SuspiciousFileOperation, msg):
    +            text.get_valid_filename('???')
    +        # After sanitizing this would yield '..'.
    +        msg = "Could not derive file name from '$.$.$'"
    +        with self.assertRaisesMessage(SuspiciousFileOperation, msg):
    +            text.get_valid_filename('$.$.$')
     
         def test_compress_sequence(self):
             data = [{'key': i} for i in range(10)]
    

Vulnerability mechanics

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

References

22

News mentions

0

No linked articles in our index yet.