Sign Up
Log In
Log In
or
Sign Up
Places
All Projects
Status Monitor
Collapse sidebar
systemsmanagement:Ardana:8:CentOS
python-Django
CVE-2021-31542.patch
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
File CVE-2021-31542.patch of Package python-Django
commit c2c3c23bdeacea4d5f78892f94270068870d6a54 Author: Florian Apolloner <florian@apolloner.eu> Date: Wed Apr 14 18:23:44 2021 +0200 [2.2.x] Fixed CVE-2021-31542 -- Tightened path & file name sanitation in file uploads. (cherry picked from commit 04ac1624bdc2fa737188401757cf95ced122d26d) diff --git a/django/core/files/storage.py b/django/core/files/storage.py index 98c89ddcfa96..003de8c0b852 100644 --- a/django/core/files/storage.py +++ b/django/core/files/storage.py @@ -1,5 +1,6 @@ import errno import os +import pathlib import warnings from datetime import datetime @@ -7,6 +8,7 @@ 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 abspathu, safe_join @@ -68,6 +70,9 @@ class Storage(object): 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 @@ -100,6 +105,8 @@ class Storage(object): """ # `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): diff --git a/django/core/files/uploadedfile.py b/django/core/files/uploadedfile.py index 6f71fc3b95ff..bbe9f428131b 100644 --- a/django/core/files/uploadedfile.py +++ b/django/core/files/uploadedfile.py @@ -9,6 +9,7 @@ from io import BytesIO 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 from django.utils.encoding import force_str __all__ = ('UploadedFile', 'TemporaryUploadedFile', 'InMemoryUploadedFile', @@ -51,6 +52,8 @@ class UploadedFile(File): ext = ext[:255] name = name[:255 - len(ext)] + ext + name = validate_file_name(name) + self._name = name name = property(_get_name, _set_name) diff --git a/django/core/files/utils.py b/django/core/files/utils.py index 8e891bf23f8a..63d320e80f4f 100644 --- a/django/core/files/utils.py +++ b/django/core/files/utils.py @@ -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(object): """ A mixin class used to forward file methods to an underlaying file diff --git a/django/db/models/fields/files.py b/django/db/models/fields/files.py index 6906b8f485cc..c9ba01b77040 100644 --- a/django/db/models/fields/files.py +++ b/django/db/models/fields/files.py @@ -8,6 +8,7 @@ from django.core import checks 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 import six @@ -323,6 +324,7 @@ class FileField(Field): 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: diff --git a/django/http/multipartparser.py b/django/http/multipartparser.py index 2bc9d0e518f6..277120235310 100644 --- a/django/http/multipartparser.py +++ b/django/http/multipartparser.py @@ -9,7 +9,7 @@ from __future__ import unicode_literals import base64 import binascii import cgi -import os +import HTMLParser import sys from django.conf import settings @@ -23,7 +23,6 @@ from django.utils import six from django.utils.datastructures import MultiValueDict from django.utils.encoding import force_text from django.utils.six.moves.urllib.parse import unquote -from django.utils.text import unescape_entities __all__ = ('MultiPartParser', 'MultiPartParserError', 'InputStreamExhausted') @@ -305,10 +304,25 @@ class MultiPartParser(object): 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 = HTMLParser.HTMLParser().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 diff --git a/django/utils/text.py b/django/utils/text.py index f221747f6f36..8b138555eeeb 100644 --- a/django/utils/text.py +++ b/django/utils/text.py @@ -5,6 +5,7 @@ import unicodedata from gzip import GzipFile from io import BytesIO +from django.core.exceptions import SuspiciousFileOperation from django.utils import six from django.utils.encoding import force_text from django.utils.functional import ( @@ -234,7 +235,7 @@ class Truncator(SimpleLazyObject): @keep_lazy_text -def get_valid_filename(s): +def get_valid_filename(name): """ Returns the given string converted to a string that can be used for a clean filename. Specifically, leading and trailing spaces are removed; other @@ -243,8 +244,11 @@ def get_valid_filename(s): >>> get_valid_filename("john's portrait in 2004.jpg") 'johns_portrait_in_2004.jpg' """ - s = force_text(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 diff --git a/docs/releases/2.2.21.txt b/docs/releases/2.2.21.txt new file mode 100644 index 000000000000..f32aeadff767 --- /dev/null +++ b/docs/releases/2.2.21.txt @@ -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. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index be5fb3e54e9a..e59c97b17ff5 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -20,6 +20,75 @@ versions of the documentation contain the release notes for any later releases. .. _development_release_notes: +2.2 release +----------- +.. toctree:: + :maxdepth: 1 + + 2.2.21 + 2.2.20 + 2.2.19 + 2.2.18 + 2.2.17 + 2.2.16 + 2.2.15 + 2.2.14 + 2.2.13 + 2.2.12 + 2.2.11 + 2.2.10 + 2.2.9 + 2.2.8 + 2.2.7 + 2.2.6 + 2.2.5 + 2.2.4 + 2.2.3 + 2.2.2 + 2.2.1 + 2.2 + +2.1 release +----------- +.. toctree:: + :maxdepth: 1 + + 2.1.15 + 2.1.14 + 2.1.13 + 2.1.12 + 2.1.11 + 2.1.10 + 2.1.9 + 2.1.8 + 2.1.7 + 2.1.6 + 2.1.5 + 2.1.4 + 2.1.3 + 2.1.2 + 2.1.1 + 2.1 + +2.0 release +----------- +.. toctree:: + :maxdepth: 1 + + 2.0.13 + 2.0.12 + 2.0.11 + 2.0.10 + 2.0.9 + 2.0.8 + 2.0.7 + 2.0.6 + 2.0.5 + 2.0.4 + 2.0.3 + 2.0.2 + 2.0.1 + 2.0 1.11 release ------------ diff --git a/tests/file_storage/test_generate_filename.py b/tests/file_storage/test_generate_filename.py index 44320138509b..6f79da39278e 100644 --- a/tests/file_storage/test_generate_filename.py +++ b/tests/file_storage/test_generate_filename.py @@ -1,8 +1,9 @@ import os import warnings +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 @@ -37,6 +38,44 @@ class AWSS3Storage(Storage): 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_get_directory_deprecation(self): with warnings.catch_warnings(record=True) as warns: diff --git a/tests/file_uploads/tests.py b/tests/file_uploads/tests.py index af7b82126cf6..86a30a6a00fb 100644 --- a/tests/file_uploads/tests.py +++ b/tests/file_uploads/tests.py @@ -12,8 +12,9 @@ import tempfile as sys_tempfile import unittest from io import BytesIO +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, parse_header from django.test import SimpleTestCase, TestCase, client, override_settings from django.utils.encoding import force_bytes @@ -42,6 +43,16 @@ CANDIDATE_TRAVERSAL_FILE_NAMES = [ '../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): @@ -57,6 +68,22 @@ class FileUploadTests(TestCase): shutil.rmtree(MEDIA_ROOT) super(FileUploadTests, cls).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 = { @@ -637,6 +664,15 @@ class MultiParserTests(unittest.TestCase): 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", diff --git a/tests/forms_tests/field_tests/test_filefield.py b/tests/forms_tests/field_tests/test_filefield.py index 59c91005d63e..a0a3e7b95364 100644 --- a/tests/forms_tests/field_tests/test_filefield.py +++ b/tests/forms_tests/field_tests/test_filefield.py @@ -23,10 +23,12 @@ class FileFieldTest(SimpleTestCase): 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') diff --git a/tests/utils_tests/test_text.py b/tests/utils_tests/test_text.py index bfc1b4efc44b..f26cbb97fcac 100644 --- a/tests/utils_tests/test_text.py +++ b/tests/utils_tests/test_text.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import json +from django.core.exceptions import SuspiciousFileOperation from django.test import SimpleTestCase from django.utils import six, text from django.utils.functional import lazystr @@ -233,6 +234,13 @@ class TestUtilsText(SimpleTestCase): 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)]
Locations
Projects
Search
Status Monitor
Help
OpenBuildService.org
Documentation
API Documentation
Code of Conduct
Contact
Support
@OBShq
Terms
openSUSE Build Service is sponsored by
The Open Build Service is an
openSUSE project
.
Sign Up
Log In
Places
Places
All Projects
Status Monitor