[2.2.x] Fixed CVE-2021-28658 -- Fixed potential directory-traversal via uploaded files.

Thanks Claude Paroz for the initial patch.
Thanks Dennis Brinkrolf for the report.

Backport of d4d800ca1a from main.
This commit is contained in:
Mariusz Felisiak 2021-03-16 10:19:00 +01:00
parent 6e58828f8b
commit 4036d62bda
7 changed files with 124 additions and 21 deletions

View File

@ -7,6 +7,7 @@ file upload handlers for processing.
import base64 import base64
import binascii import binascii
import cgi import cgi
import os
from urllib.parse import unquote from urllib.parse import unquote
from django.conf import settings from django.conf import settings
@ -205,7 +206,7 @@ class MultiPartParser:
file_name = disposition.get('filename') file_name = disposition.get('filename')
if file_name: if file_name:
file_name = force_text(file_name, encoding, errors='replace') file_name = force_text(file_name, encoding, errors='replace')
file_name = self.IE_sanitize(unescape_entities(file_name)) file_name = self.sanitize_file_name(file_name)
if not file_name: if not file_name:
continue continue
@ -293,9 +294,13 @@ class MultiPartParser:
self._files.appendlist(force_text(old_field_name, self._encoding, errors='replace'), file_obj) self._files.appendlist(force_text(old_field_name, self._encoding, errors='replace'), file_obj)
break break
def IE_sanitize(self, filename): def sanitize_file_name(self, file_name):
"""Cleanup filename from Internet Explorer full paths.""" file_name = unescape_entities(file_name)
return filename and filename[filename.rfind("\\") + 1:].strip() # Cleanup Windows-style path separators.
file_name = file_name[file_name.rfind('\\') + 1:].strip()
return os.path.basename(file_name)
IE_sanitize = sanitize_file_name
def _close_files(self): def _close_files(self):
# Free up all file handles. # Free up all file handles.

15
docs/releases/2.2.20.txt Normal file
View File

@ -0,0 +1,15 @@
===========================
Django 2.2.20 release notes
===========================
*April 6, 2021*
Django 2.2.20 fixes a security issue with severity "low" in 2.2.19.
CVE-2021-28658: Potential directory-traversal via uploaded files
================================================================
``MultiPartParser`` allowed directory-traversal via uploaded files with
suitably crafted file names.
Built-in upload handlers were not affected by this vulnerability.

View File

@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1
2.2.20
2.2.19 2.2.19
2.2.18 2.2.18
2.2.17 2.2.17

View File

@ -22,6 +22,21 @@ UNICODE_FILENAME = 'test-0123456789_中文_Orléans.jpg'
MEDIA_ROOT = sys_tempfile.mkdtemp() MEDIA_ROOT = sys_tempfile.mkdtemp()
UPLOAD_TO = os.path.join(MEDIA_ROOT, 'test_upload') UPLOAD_TO = os.path.join(MEDIA_ROOT, 'test_upload')
CANDIDATE_TRAVERSAL_FILE_NAMES = [
'/tmp/hax0rd.txt', # Absolute path, *nix-style.
'C:\\Windows\\hax0rd.txt', # Absolute path, win-style.
'C:/Windows/hax0rd.txt', # Absolute path, broken-style.
'\\tmp\\hax0rd.txt', # Absolute path, broken in a different way.
'/tmp\\hax0rd.txt', # Absolute path, broken by mixing.
'subdir/hax0rd.txt', # Descendant path, *nix-style.
'subdir\\hax0rd.txt', # Descendant path, win-style.
'sub/dir\\hax0rd.txt', # Descendant path, mixed.
'../../hax0rd.txt', # Relative path, *nix-style.
'..\\..\\hax0rd.txt', # Relative path, win-style.
'../..\\hax0rd.txt', # Relative path, mixed.
'../hax0rd.txt', # HTML entities.
]
@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):
@ -205,22 +220,8 @@ class FileUploadTests(TestCase):
# a malicious payload with an invalid file name (containing os.sep or # a malicious payload with an invalid file name (containing os.sep or
# os.pardir). This similar to what an attacker would need to do when # os.pardir). This similar to what an attacker would need to do when
# trying such an attack. # trying such an attack.
scary_file_names = [
"/tmp/hax0rd.txt", # Absolute path, *nix-style.
"C:\\Windows\\hax0rd.txt", # Absolute path, win-style.
"C:/Windows/hax0rd.txt", # Absolute path, broken-style.
"\\tmp\\hax0rd.txt", # Absolute path, broken in a different way.
"/tmp\\hax0rd.txt", # Absolute path, broken by mixing.
"subdir/hax0rd.txt", # Descendant path, *nix-style.
"subdir\\hax0rd.txt", # Descendant path, win-style.
"sub/dir\\hax0rd.txt", # Descendant path, mixed.
"../../hax0rd.txt", # Relative path, *nix-style.
"..\\..\\hax0rd.txt", # Relative path, win-style.
"../..\\hax0rd.txt" # Relative path, mixed.
]
payload = client.FakePayload() payload = client.FakePayload()
for i, name in enumerate(scary_file_names): for i, name in enumerate(CANDIDATE_TRAVERSAL_FILE_NAMES):
payload.write('\r\n'.join([ payload.write('\r\n'.join([
'--' + client.BOUNDARY, '--' + client.BOUNDARY,
'Content-Disposition: form-data; name="file%s"; filename="%s"' % (i, name), 'Content-Disposition: form-data; name="file%s"; filename="%s"' % (i, name),
@ -240,7 +241,7 @@ class FileUploadTests(TestCase):
response = self.client.request(**r) response = self.client.request(**r)
# The filenames should have been sanitized by the time it got to the view. # The filenames should have been sanitized by the time it got to the view.
received = response.json() received = response.json()
for i, name in enumerate(scary_file_names): for i, name in enumerate(CANDIDATE_TRAVERSAL_FILE_NAMES):
got = received["file%s" % i] got = received["file%s" % i]
self.assertEqual(got, "hax0rd.txt") self.assertEqual(got, "hax0rd.txt")
@ -518,6 +519,36 @@ class FileUploadTests(TestCase):
# shouldn't differ. # shouldn't differ.
self.assertEqual(os.path.basename(obj.testfile.path), 'MiXeD_cAsE.txt') self.assertEqual(os.path.basename(obj.testfile.path), 'MiXeD_cAsE.txt')
def test_filename_traversal_upload(self):
os.makedirs(UPLOAD_TO, exist_ok=True)
self.addCleanup(shutil.rmtree, MEDIA_ROOT)
file_name = '../test.txt',
payload = client.FakePayload()
payload.write(
'\r\n'.join([
'--' + client.BOUNDARY,
'Content-Disposition: form-data; name="my_file"; '
'filename="%s";' % file_name,
'Content-Type: text/plain',
'',
'file contents.\r\n',
'\r\n--' + client.BOUNDARY + '--\r\n',
]),
)
r = {
'CONTENT_LENGTH': len(payload),
'CONTENT_TYPE': client.MULTIPART_CONTENT,
'PATH_INFO': '/upload_traversal/',
'REQUEST_METHOD': 'POST',
'wsgi.input': payload,
}
response = self.client.request(**r)
result = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(result['file_name'], 'test.txt')
self.assertIs(os.path.exists(os.path.join(MEDIA_ROOT, 'test.txt')), False)
self.assertIs(os.path.exists(os.path.join(UPLOAD_TO, 'test.txt')), True)
@override_settings(MEDIA_ROOT=MEDIA_ROOT) @override_settings(MEDIA_ROOT=MEDIA_ROOT)
class DirectoryCreationTests(SimpleTestCase): class DirectoryCreationTests(SimpleTestCase):
@ -591,6 +622,15 @@ class MultiParserTests(SimpleTestCase):
}, StringIO('x'), [], 'utf-8') }, StringIO('x'), [], 'utf-8')
self.assertEqual(multipart_parser._content_length, 0) self.assertEqual(multipart_parser._content_length, 0)
def test_sanitize_file_name(self):
parser = MultiPartParser({
'CONTENT_TYPE': 'multipart/form-data; boundary=_foo',
'CONTENT_LENGTH': '1'
}, StringIO('x'), [], 'utf-8')
for file_name in CANDIDATE_TRAVERSAL_FILE_NAMES:
with self.subTest(file_name=file_name):
self.assertEqual(parser.sanitize_file_name(file_name), 'hax0rd.txt')
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

@ -1,6 +1,8 @@
""" """
Upload handlers to test the upload API. Upload handlers to test the upload API.
""" """
import os
from tempfile import NamedTemporaryFile
from django.core.files.uploadhandler import FileUploadHandler, StopUpload from django.core.files.uploadhandler import FileUploadHandler, StopUpload
@ -35,3 +37,32 @@ class ErroringUploadHandler(FileUploadHandler):
"""A handler that raises an exception.""" """A handler that raises an exception."""
def receive_data_chunk(self, raw_data, start): def receive_data_chunk(self, raw_data, start):
raise CustomUploadError("Oops!") raise CustomUploadError("Oops!")
class TraversalUploadHandler(FileUploadHandler):
"""A handler with potential directory-traversal vulnerability."""
def __init__(self, request=None):
from .views import UPLOAD_TO
super().__init__(request)
self.upload_dir = UPLOAD_TO
def file_complete(self, file_size):
self.file.seek(0)
self.file.size = file_size
with open(os.path.join(self.upload_dir, self.file_name), 'wb') as fp:
fp.write(self.file.read())
return self.file
def new_file(
self, field_name, file_name, content_type, content_length, charset=None,
content_type_extra=None,
):
super().new_file(
file_name, file_name, content_length, content_length, charset,
content_type_extra,
)
self.file = NamedTemporaryFile(suffix='.upload', dir=self.upload_dir)
def receive_data_chunk(self, raw_data, start):
self.file.write(raw_data)

View File

@ -4,6 +4,7 @@ from . import views
urlpatterns = [ urlpatterns = [
path('upload/', views.file_upload_view), path('upload/', views.file_upload_view),
path('upload_traversal/', views.file_upload_traversal_view),
path('verify/', views.file_upload_view_verify), path('verify/', views.file_upload_view_verify),
path('unicode_name/', views.file_upload_unicode_name), path('unicode_name/', views.file_upload_unicode_name),
path('echo/', views.file_upload_echo), path('echo/', views.file_upload_echo),

View File

@ -6,7 +6,9 @@ from django.http import HttpResponse, HttpResponseServerError, JsonResponse
from .models import FileModel from .models import FileModel
from .tests import UNICODE_FILENAME, UPLOAD_TO from .tests import UNICODE_FILENAME, UPLOAD_TO
from .uploadhandler import ErroringUploadHandler, QuotaUploadHandler from .uploadhandler import (
ErroringUploadHandler, QuotaUploadHandler, TraversalUploadHandler,
)
def file_upload_view(request): def file_upload_view(request):
@ -158,3 +160,11 @@ def file_upload_fd_closing(request, access):
if access == 't': if access == 't':
request.FILES # Trigger file parsing. request.FILES # Trigger file parsing.
return HttpResponse('') return HttpResponse('')
def file_upload_traversal_view(request):
request.upload_handlers.insert(0, TraversalUploadHandler())
request.FILES # Trigger file parsing.
return JsonResponse(
{'file_name': request.upload_handlers[0].file_name},
)