Fixed CVE-2021-31542 -- Tightened path & file name sanitation in file uploads.

This commit is contained in:
Florian Apolloner 2021-04-14 18:23:44 +02:00 committed by Carlton Gibson
parent 8de4ca74ba
commit 0b79eb3691
14 changed files with 190 additions and 13 deletions

View File

@ -1,4 +1,5 @@
import os import os
import pathlib
from datetime import datetime from datetime import datetime
from urllib.parse import urljoin from urllib.parse import urljoin
@ -6,6 +7,7 @@ from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation from django.core.exceptions import SuspiciousFileOperation
from django.core.files import File, locks from django.core.files import File, locks
from django.core.files.move import file_move_safe 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.core.signals import setting_changed
from django.utils import timezone from django.utils import timezone
from django.utils._os import safe_join from django.utils._os import safe_join
@ -74,6 +76,9 @@ class Storage:
available for new content to be written to. available for new content to be written to.
""" """
dir_name, file_name = os.path.split(name) 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) file_root, file_ext = os.path.splitext(file_name)
# If the filename already exists, generate an alternative filename # If the filename already exists, generate an alternative filename
# until it doesn't exist. # until it doesn't exist.
@ -105,6 +110,8 @@ class Storage:
""" """
# `filename` may include a path as returned by FileField.upload_to. # `filename` may include a path as returned by FileField.upload_to.
dirname, filename = os.path.split(filename) 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))) return os.path.normpath(os.path.join(dirname, self.get_valid_name(filename)))
def path(self, name): def path(self, name):

View File

@ -8,6 +8,7 @@ from io import BytesIO
from django.conf import settings from django.conf import settings
from django.core.files import temp as tempfile from django.core.files import temp as tempfile
from django.core.files.base import File from django.core.files.base import File
from django.core.files.utils import validate_file_name
__all__ = ('UploadedFile', 'TemporaryUploadedFile', 'InMemoryUploadedFile', __all__ = ('UploadedFile', 'TemporaryUploadedFile', 'InMemoryUploadedFile',
'SimpleUploadedFile') 'SimpleUploadedFile')
@ -47,6 +48,8 @@ class UploadedFile(File):
ext = ext[:255] ext = ext[:255]
name = name[:255 - len(ext)] + ext name = name[:255 - len(ext)] + ext
name = validate_file_name(name)
self._name = name self._name = name
name = property(_get_name, _set_name) name = property(_get_name, _set_name)

View File

@ -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: class FileProxyMixin:
""" """
A mixin class used to forward file methods to an underlaying file A mixin class used to forward file methods to an underlaying file

View File

@ -6,6 +6,7 @@ from django.core import checks
from django.core.files.base import File from django.core.files.base import File
from django.core.files.images import ImageFile from django.core.files.images import ImageFile
from django.core.files.storage import Storage, default_storage 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 import signals
from django.db.models.fields import Field from django.db.models.fields import Field
from django.db.models.query_utils import DeferredAttribute from django.db.models.query_utils import DeferredAttribute
@ -312,6 +313,7 @@ class FileField(Field):
Until the storage layer, all file paths are expected to be Unix style Until the storage layer, all file paths are expected to be Unix style
(with forward slashes). (with forward slashes).
""" """
filename = validate_file_name(filename)
if callable(self.upload_to): if callable(self.upload_to):
filename = self.upload_to(instance, filename) filename = self.upload_to(instance, filename)
else: else:

View File

@ -9,7 +9,6 @@ import binascii
import cgi import cgi
import collections import collections
import html import html
import os
from urllib.parse import unquote from urllib.parse import unquote
from django.conf import settings from django.conf import settings
@ -306,10 +305,25 @@ class MultiPartParser:
break break
def sanitize_file_name(self, file_name): 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) file_name = html.unescape(file_name)
# Cleanup Windows-style path separators. file_name = file_name.rsplit('/')[-1]
file_name = file_name[file_name.rfind('\\') + 1:].strip() file_name = file_name.rsplit('\\')[-1]
return os.path.basename(file_name)
if file_name in {'', '.', '..'}:
return None
return file_name
IE_sanitize = sanitize_file_name IE_sanitize = sanitize_file_name

View File

@ -4,6 +4,7 @@ import unicodedata
from gzip import GzipFile from gzip import GzipFile
from io import BytesIO from io import BytesIO
from django.core.exceptions import SuspiciousFileOperation
from django.utils.functional import SimpleLazyObject, keep_lazy_text, lazy from django.utils.functional import SimpleLazyObject, keep_lazy_text, lazy
from django.utils.regex_helper import _lazy_re_compile from django.utils.regex_helper import _lazy_re_compile
from django.utils.translation import gettext as _, gettext_lazy, pgettext from django.utils.translation import gettext as _, gettext_lazy, pgettext
@ -221,7 +222,7 @@ class Truncator(SimpleLazyObject):
@keep_lazy_text @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 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 filename. Remove leading and trailing spaces; convert other spaces to
@ -230,8 +231,11 @@ def get_valid_filename(s):
>>> get_valid_filename("john's portrait in 2004.jpg") >>> get_valid_filename("john's portrait in 2004.jpg")
'johns_portrait_in_2004.jpg' 'johns_portrait_in_2004.jpg'
""" """
s = str(s).strip().replace(' ', '_') s = str(name).strip().replace(' ', '_')
return re.sub(r'(?u)[^-\w.]', '', s) 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 @keep_lazy_text

17
docs/releases/2.2.21.txt Normal file
View File

@ -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.

17
docs/releases/3.1.9.txt Normal file
View File

@ -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.

View File

@ -2,9 +2,19 @@
Django 3.2.1 release notes 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 Bugfixes
======== ========

View File

@ -40,6 +40,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1
3.1.9
3.1.8 3.1.8
3.1.7 3.1.7
3.1.6 3.1.6
@ -76,6 +77,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1
2.2.21
2.2.20 2.2.20
2.2.19 2.2.19
2.2.18 2.2.18

View File

@ -1,7 +1,8 @@
import os import os
from django.core.exceptions import SuspiciousFileOperation
from django.core.files.base import ContentFile 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.db.models import FileField
from django.test import SimpleTestCase from django.test import SimpleTestCase
@ -36,6 +37,44 @@ class AWSS3Storage(Storage):
class GenerateFilenameStorageTests(SimpleTestCase): 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): def test_filefield_generate_filename(self):
f = FileField(upload_to='some/folder/') f = FileField(upload_to='some/folder/')

View File

@ -9,8 +9,9 @@ from io import BytesIO, StringIO
from unittest import mock from unittest import mock
from urllib.parse import quote from urllib.parse import quote
from django.core.exceptions import SuspiciousFileOperation
from django.core.files import temp as tempfile 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 ( from django.http.multipartparser import (
FILE, MultiPartParser, MultiPartParserError, Parser, parse_header, FILE, MultiPartParser, MultiPartParserError, Parser, parse_header,
) )
@ -39,6 +40,16 @@ CANDIDATE_TRAVERSAL_FILE_NAMES = [
'../hax0rd.txt', # HTML entities. '../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=[]) @override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE=[])
class FileUploadTests(TestCase): class FileUploadTests(TestCase):
@ -53,6 +64,22 @@ class FileUploadTests(TestCase):
shutil.rmtree(MEDIA_ROOT) shutil.rmtree(MEDIA_ROOT)
super().tearDownClass() 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): def test_simple_upload(self):
with open(__file__, 'rb') as fp: with open(__file__, 'rb') as fp:
post_data = { post_data = {
@ -718,6 +745,15 @@ class MultiParserTests(SimpleTestCase):
with self.subTest(file_name=file_name): with self.subTest(file_name=file_name):
self.assertEqual(parser.sanitize_file_name(file_name), 'hax0rd.txt') 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): def test_rfc2231_parsing(self):
test_data = ( test_data = (
(b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A", (b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A",

View File

@ -21,10 +21,12 @@ class FileFieldTest(SimpleTestCase):
f.clean(None, '') f.clean(None, '')
self.assertEqual('files/test2.pdf', f.clean(None, 'files/test2.pdf')) 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.'" 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): with self.assertRaisesMessage(ValidationError, no_file_msg):
f.clean(SimpleUploadedFile('', b'')) f.clean(file)
with self.assertRaisesMessage(ValidationError, no_file_msg): 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')) self.assertEqual('files/test3.pdf', f.clean(None, 'files/test3.pdf'))
with self.assertRaisesMessage(ValidationError, no_file_msg): with self.assertRaisesMessage(ValidationError, no_file_msg):
f.clean('some content that is not a file') f.clean('some content that is not a file')

View File

@ -1,6 +1,7 @@
import json import json
import sys import sys
from django.core.exceptions import SuspiciousFileOperation
from django.test import SimpleTestCase from django.test import SimpleTestCase
from django.utils import text from django.utils import text
from django.utils.functional import lazystr from django.utils.functional import lazystr
@ -228,6 +229,13 @@ class TestUtilsText(SimpleTestCase):
filename = "^&'@{}[],$=!-#()%+~_123.txt" filename = "^&'@{}[],$=!-#()%+~_123.txt"
self.assertEqual(text.get_valid_filename(filename), "-_123.txt") self.assertEqual(text.get_valid_filename(filename), "-_123.txt")
self.assertEqual(text.get_valid_filename(lazystr(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): def test_compress_sequence(self):
data = [{'key': i} for i in range(10)] data = [{'key': i} for i in range(10)]