diff -Nru python-django-2.2.12/debian/changelog python-django-2.2.12/debian/changelog --- python-django-2.2.12/debian/changelog 2021-03-30 18:53:19.000000000 +0000 +++ python-django-2.2.12/debian/changelog 2021-04-28 10:39:44.000000000 +0000 @@ -1,3 +1,17 @@ +python-django (2:2.2.12-1ubuntu0.6) focal-security; urgency=medium + + * SECURITY UPDATE: Potential directory-traversal via uploaded files + - debian/patches/CVE-2021-31542.patch: tighten path & file name + sanitation in file uploads in django/core/files/storage.py, + django/core/files/uploadedfile.py, django/core/files/utils.py, + django/db/models/fields/files.py, django/http/multipartparser.py, + django/utils/text.py, tests/file_storage/test_generate_filename.py, + tests/file_uploads/tests.py, tests/utils_tests/test_text.py, + tests/forms_tests/field_tests/test_filefield.py. + - CVE-2021-31542 + + -- Marc Deslauriers Wed, 28 Apr 2021 06:39:44 -0400 + python-django (2:2.2.12-1ubuntu0.5) focal-security; urgency=medium * SECURITY UPDATE: Potential directory-traversal via uploaded files diff -Nru python-django-2.2.12/debian/patches/CVE-2021-31542.patch python-django-2.2.12/debian/patches/CVE-2021-31542.patch --- python-django-2.2.12/debian/patches/CVE-2021-31542.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-2.2.12/debian/patches/CVE-2021-31542.patch 2021-04-28 10:39:39.000000000 +0000 @@ -0,0 +1,388 @@ +From 04ac1624bdc2fa737188401757cf95ced122d26d Mon Sep 17 00:00:00 2001 +From: Florian Apolloner +Date: Wed, 14 Apr 2021 18:23:44 +0200 +Subject: [PATCH] [2.2.x] Fixed CVE-2021-31542 -- Tightened path & file name + sanitation in file uploads. + +--- + django/core/files/storage.py | 7 ++++ + django/core/files/uploadedfile.py | 3 ++ + django/core/files/utils.py | 16 ++++++++ + django/db/models/fields/files.py | 2 + + django/http/multipartparser.py | 26 +++++++++--- + django/utils/text.py | 10 +++-- + docs/releases/2.2.21.txt | 17 ++++++++ + docs/releases/index.txt | 1 + + tests/file_storage/test_generate_filename.py | 41 ++++++++++++++++++- + tests/file_uploads/tests.py | 38 ++++++++++++++++- + .../forms_tests/field_tests/test_filefield.py | 6 ++- + tests/utils_tests/test_text.py | 8 ++++ + 12 files changed, 162 insertions(+), 13 deletions(-) + create mode 100644 docs/releases/2.2.21.txt + +diff --git a/django/core/files/storage.py b/django/core/files/storage.py +index 1562614e50..89faa626e6 100644 +--- a/django/core/files/storage.py ++++ b/django/core/files/storage.py +@@ -1,4 +1,5 @@ + import os ++import pathlib + from datetime import datetime + from urllib.parse import urljoin + +@@ -6,6 +7,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 safe_join +@@ -66,6 +68,9 @@ class Storage: + 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 @@ class Storage: + """ + # `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 48007b8682..f452bcd9a4 100644 +--- a/django/core/files/uploadedfile.py ++++ b/django/core/files/uploadedfile.py +@@ -8,6 +8,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 + + __all__ = ('UploadedFile', 'TemporaryUploadedFile', 'InMemoryUploadedFile', + 'SimpleUploadedFile') +@@ -47,6 +48,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 de89607175..f83cb1a3cf 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: + """ + 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 bd8da95e46..d53bd42bee 100644 +--- a/django/db/models/fields/files.py ++++ b/django/db/models/fields/files.py +@@ -6,6 +6,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.translation import gettext_lazy as _ +@@ -299,6 +300,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 5a9cca89e6..4570ebbaee 100644 +--- a/django/http/multipartparser.py ++++ b/django/http/multipartparser.py +@@ -7,7 +7,7 @@ file upload handlers for processing. + 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.core.files.uploadhandler import ( + ) + 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 @@ class MultiPartParser: + 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 + +diff --git a/django/utils/text.py b/django/utils/text.py +index 853436a38f..1fae7b2522 100644 +--- a/django/utils/text.py ++++ b/django/utils/text.py +@@ -4,6 +4,7 @@ import unicodedata + 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 @@ class Truncator(SimpleLazyObject): + + + @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 +diff --git a/tests/file_storage/test_generate_filename.py b/tests/file_storage/test_generate_filename.py +index b4222f4121..9f54f6921e 100644 +--- a/tests/file_storage/test_generate_filename.py ++++ b/tests/file_storage/test_generate_filename.py +@@ -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 @@ 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_generate_filename(self): + f = FileField(upload_to='some/folder/') +diff --git a/tests/file_uploads/tests.py b/tests/file_uploads/tests.py +index 2a08d1ba01..3afcbfd4ad 100644 +--- a/tests/file_uploads/tests.py ++++ b/tests/file_uploads/tests.py +@@ -8,8 +8,9 @@ import unittest + 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 @@ 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): +@@ -52,6 +63,22 @@ class FileUploadTests(TestCase): + 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 @@ class MultiParserTests(SimpleTestCase): + 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 fc5c4b5c1e..33574446f4 100644 +--- a/tests/forms_tests/field_tests/test_filefield.py ++++ b/tests/forms_tests/field_tests/test_filefield.py +@@ -20,10 +20,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 cab324d64e..27e440b856 100644 +--- a/tests/utils_tests/test_text.py ++++ b/tests/utils_tests/test_text.py +@@ -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 @@ 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)] +-- +2.31.1 + diff -Nru python-django-2.2.12/debian/patches/series python-django-2.2.12/debian/patches/series --- python-django-2.2.12/debian/patches/series 2021-03-30 18:53:14.000000000 +0000 +++ python-django-2.2.12/debian/patches/series 2021-04-28 10:39:39.000000000 +0000 @@ -10,3 +10,4 @@ CVE-2021-3281.patch CVE-2021-23336.patch CVE-2021-28658.patch +CVE-2021-31542.patch