Skip to content

Commit

Permalink
[2.2.x] Fixed CVE-2021-31542 -- Tightened path & file name sanitation…
Browse files Browse the repository at this point in the history
… in file uploads.
  • Loading branch information
apollo13 authored and carltongibson committed Apr 27, 2021
1 parent 7f1b088 commit 04ac162
Show file tree
Hide file tree
Showing 12 changed files with 162 additions and 13 deletions.
7 changes: 7 additions & 0 deletions django/core/files/storage.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions django/core/files/uploadedfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions django/core/files/utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions django/db/models/fields/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 20 additions & 6 deletions django/http/multipartparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import base64
import binascii
import cgi
import os
import html
from urllib.parse import unquote

from django.conf import settings
Expand All @@ -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')

Expand Down Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions django/utils/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/releases/2.2.21.txt
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/releases/index.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 40 additions & 1 deletion tests/file_storage/test_generate_filename.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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/')
Expand Down
38 changes: 37 additions & 1 deletion tests/file_uploads/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -37,6 +38,16 @@
'../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):
Expand All @@ -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 = {
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions tests/forms_tests/field_tests/test_filefield.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
8 changes: 8 additions & 0 deletions tests/utils_tests/test_text.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)]
Expand Down

0 comments on commit 04ac162

Please sign in to comment.