Skip to content

Commit 04ac162

Browse files
apollo13carltongibson
authored andcommitted
[2.2.x] Fixed CVE-2021-31542 -- Tightened path & file name sanitation in file uploads.
1 parent 7f1b088 commit 04ac162

File tree

12 files changed

+162
-13
lines changed

12 files changed

+162
-13
lines changed

django/core/files/storage.py

+7
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import os
2+
import pathlib
23
from datetime import datetime
34
from urllib.parse import urljoin
45

56
from django.conf import settings
67
from django.core.exceptions import SuspiciousFileOperation
78
from django.core.files import File, locks
89
from django.core.files.move import file_move_safe
10+
from django.core.files.utils import validate_file_name
911
from django.core.signals import setting_changed
1012
from django.utils import timezone
1113
from django.utils._os import safe_join
@@ -66,6 +68,9 @@ def get_available_name(self, name, max_length=None):
6668
available for new content to be written to.
6769
"""
6870
dir_name, file_name = os.path.split(name)
71+
if '..' in pathlib.PurePath(dir_name).parts:
72+
raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dir_name)
73+
validate_file_name(file_name)
6974
file_root, file_ext = os.path.splitext(file_name)
7075
# If the filename already exists, add an underscore and a random 7
7176
# character alphanumeric string (before the file extension, if one
@@ -98,6 +103,8 @@ def generate_filename(self, filename):
98103
"""
99104
# `filename` may include a path as returned by FileField.upload_to.
100105
dirname, filename = os.path.split(filename)
106+
if '..' in pathlib.PurePath(dirname).parts:
107+
raise SuspiciousFileOperation("Detected path traversal attempt in '%s'" % dirname)
101108
return os.path.normpath(os.path.join(dirname, self.get_valid_name(filename)))
102109

103110
def path(self, name):

django/core/files/uploadedfile.py

+3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.conf import settings
99
from django.core.files import temp as tempfile
1010
from django.core.files.base import File
11+
from django.core.files.utils import validate_file_name
1112

1213
__all__ = ('UploadedFile', 'TemporaryUploadedFile', 'InMemoryUploadedFile',
1314
'SimpleUploadedFile')
@@ -47,6 +48,8 @@ def _set_name(self, name):
4748
ext = ext[:255]
4849
name = name[:255 - len(ext)] + ext
4950

51+
name = validate_file_name(name)
52+
5053
self._name = name
5154

5255
name = property(_get_name, _set_name)

django/core/files/utils.py

+16
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
import os
2+
3+
from django.core.exceptions import SuspiciousFileOperation
4+
5+
6+
def validate_file_name(name):
7+
if name != os.path.basename(name):
8+
raise SuspiciousFileOperation("File name '%s' includes path elements" % name)
9+
10+
# Remove potentially dangerous names
11+
if name in {'', '.', '..'}:
12+
raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
13+
14+
return name
15+
16+
117
class FileProxyMixin:
218
"""
319
A mixin class used to forward file methods to an underlaying file

django/db/models/fields/files.py

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.core.files.base import File
77
from django.core.files.images import ImageFile
88
from django.core.files.storage import default_storage
9+
from django.core.files.utils import validate_file_name
910
from django.db.models import signals
1011
from django.db.models.fields import Field
1112
from django.utils.translation import gettext_lazy as _
@@ -299,6 +300,7 @@ def generate_filename(self, instance, filename):
299300
Until the storage layer, all file paths are expected to be Unix style
300301
(with forward slashes).
301302
"""
303+
filename = validate_file_name(filename)
302304
if callable(self.upload_to):
303305
filename = self.upload_to(instance, filename)
304306
else:

django/http/multipartparser.py

+20-6
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import base64
88
import binascii
99
import cgi
10-
import os
10+
import html
1111
from urllib.parse import unquote
1212

1313
from django.conf import settings
@@ -19,7 +19,6 @@
1919
)
2020
from django.utils.datastructures import MultiValueDict
2121
from django.utils.encoding import force_text
22-
from django.utils.text import unescape_entities
2322

2423
__all__ = ('MultiPartParser', 'MultiPartParserError', 'InputStreamExhausted')
2524

@@ -295,10 +294,25 @@ def handle_file_complete(self, old_field_name, counters):
295294
break
296295

297296
def sanitize_file_name(self, file_name):
298-
file_name = unescape_entities(file_name)
299-
# Cleanup Windows-style path separators.
300-
file_name = file_name[file_name.rfind('\\') + 1:].strip()
301-
return os.path.basename(file_name)
297+
"""
298+
Sanitize the filename of an upload.
299+
300+
Remove all possible path separators, even though that might remove more
301+
than actually required by the target system. Filenames that could
302+
potentially cause problems (current/parent dir) are also discarded.
303+
304+
It should be noted that this function could still return a "filepath"
305+
like "C:some_file.txt" which is handled later on by the storage layer.
306+
So while this function does sanitize filenames to some extent, the
307+
resulting filename should still be considered as untrusted user input.
308+
"""
309+
file_name = html.unescape(file_name)
310+
file_name = file_name.rsplit('/')[-1]
311+
file_name = file_name.rsplit('\\')[-1]
312+
313+
if file_name in {'', '.', '..'}:
314+
return None
315+
return file_name
302316

303317
IE_sanitize = sanitize_file_name
304318

django/utils/text.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from gzip import GzipFile
55
from io import BytesIO
66

7+
from django.core.exceptions import SuspiciousFileOperation
78
from django.utils.functional import SimpleLazyObject, keep_lazy_text, lazy
89
from django.utils.translation import gettext as _, gettext_lazy, pgettext
910

@@ -216,7 +217,7 @@ def _truncate_html(self, length, truncate, text, truncate_len, words):
216217

217218

218219
@keep_lazy_text
219-
def get_valid_filename(s):
220+
def get_valid_filename(name):
220221
"""
221222
Return the given string converted to a string that can be used for a clean
222223
filename. Remove leading and trailing spaces; convert other spaces to
@@ -225,8 +226,11 @@ def get_valid_filename(s):
225226
>>> get_valid_filename("john's portrait in 2004.jpg")
226227
'johns_portrait_in_2004.jpg'
227228
"""
228-
s = str(s).strip().replace(' ', '_')
229-
return re.sub(r'(?u)[^-\w.]', '', s)
229+
s = str(name).strip().replace(' ', '_')
230+
s = re.sub(r'(?u)[^-\w.]', '', s)
231+
if s in {'', '.', '..'}:
232+
raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
233+
return s
230234

231235

232236
@keep_lazy_text

docs/releases/2.2.21.txt

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
===========================
2+
Django 2.2.21 release notes
3+
===========================
4+
5+
*May 4, 2021*
6+
7+
Django 2.2.21 fixes a security issue in 2.2.20.
8+
9+
CVE-2021-31542: Potential directory-traversal via uploaded files
10+
================================================================
11+
12+
``MultiPartParser``, ``UploadedFile``, and ``FieldFile`` allowed
13+
directory-traversal via uploaded files with suitably crafted file names.
14+
15+
In order to mitigate this risk, stricter basename and path sanitation is now
16+
applied. Specifically, empty file names and paths with dot segments will be
17+
rejected.

docs/releases/index.txt

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases.
2525
.. toctree::
2626
:maxdepth: 1
2727

28+
2.2.21
2829
2.2.20
2930
2.2.19
3031
2.2.18

tests/file_storage/test_generate_filename.py

+40-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import os
22

3+
from django.core.exceptions import SuspiciousFileOperation
34
from django.core.files.base import ContentFile
4-
from django.core.files.storage import Storage
5+
from django.core.files.storage import FileSystemStorage, Storage
56
from django.db.models import FileField
67
from django.test import SimpleTestCase
78

@@ -36,6 +37,44 @@ def generate_filename(self, filename):
3637

3738

3839
class GenerateFilenameStorageTests(SimpleTestCase):
40+
def test_storage_dangerous_paths(self):
41+
candidates = [
42+
('/tmp/..', '..'),
43+
('/tmp/.', '.'),
44+
('', ''),
45+
]
46+
s = FileSystemStorage()
47+
msg = "Could not derive file name from '%s'"
48+
for file_name, base_name in candidates:
49+
with self.subTest(file_name=file_name):
50+
with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name):
51+
s.get_available_name(file_name)
52+
with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name):
53+
s.generate_filename(file_name)
54+
55+
def test_storage_dangerous_paths_dir_name(self):
56+
file_name = '/tmp/../path'
57+
s = FileSystemStorage()
58+
msg = "Detected path traversal attempt in '/tmp/..'"
59+
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
60+
s.get_available_name(file_name)
61+
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
62+
s.generate_filename(file_name)
63+
64+
def test_filefield_dangerous_filename(self):
65+
candidates = ['..', '.', '', '???', '$.$.$']
66+
f = FileField(upload_to='some/folder/')
67+
msg = "Could not derive file name from '%s'"
68+
for file_name in candidates:
69+
with self.subTest(file_name=file_name):
70+
with self.assertRaisesMessage(SuspiciousFileOperation, msg % file_name):
71+
f.generate_filename(None, file_name)
72+
73+
def test_filefield_dangerous_filename_dir(self):
74+
f = FileField(upload_to='some/folder/')
75+
msg = "File name '/tmp/path' includes path elements"
76+
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
77+
f.generate_filename(None, '/tmp/path')
3978

4079
def test_filefield_generate_filename(self):
4180
f = FileField(upload_to='some/folder/')

tests/file_uploads/tests.py

+37-1
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
from io import BytesIO, StringIO
99
from urllib.parse import quote
1010

11+
from django.core.exceptions import SuspiciousFileOperation
1112
from django.core.files import temp as tempfile
12-
from django.core.files.uploadedfile import SimpleUploadedFile
13+
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
1314
from django.http.multipartparser import (
1415
MultiPartParser, MultiPartParserError, parse_header,
1516
)
@@ -37,6 +38,16 @@
3738
'../hax0rd.txt', # HTML entities.
3839
]
3940

41+
CANDIDATE_INVALID_FILE_NAMES = [
42+
'/tmp/', # Directory, *nix-style.
43+
'c:\\tmp\\', # Directory, win-style.
44+
'/tmp/.', # Directory dot, *nix-style.
45+
'c:\\tmp\\.', # Directory dot, *nix-style.
46+
'/tmp/..', # Parent directory, *nix-style.
47+
'c:\\tmp\\..', # Parent directory, win-style.
48+
'', # Empty filename.
49+
]
50+
4051

4152
@override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE=[])
4253
class FileUploadTests(TestCase):
@@ -52,6 +63,22 @@ def tearDownClass(cls):
5263
shutil.rmtree(MEDIA_ROOT)
5364
super().tearDownClass()
5465

66+
def test_upload_name_is_validated(self):
67+
candidates = [
68+
'/tmp/',
69+
'/tmp/..',
70+
'/tmp/.',
71+
]
72+
if sys.platform == 'win32':
73+
candidates.extend([
74+
'c:\\tmp\\',
75+
'c:\\tmp\\..',
76+
'c:\\tmp\\.',
77+
])
78+
for file_name in candidates:
79+
with self.subTest(file_name=file_name):
80+
self.assertRaises(SuspiciousFileOperation, UploadedFile, name=file_name)
81+
5582
def test_simple_upload(self):
5683
with open(__file__, 'rb') as fp:
5784
post_data = {
@@ -631,6 +658,15 @@ def test_sanitize_file_name(self):
631658
with self.subTest(file_name=file_name):
632659
self.assertEqual(parser.sanitize_file_name(file_name), 'hax0rd.txt')
633660

661+
def test_sanitize_invalid_file_name(self):
662+
parser = MultiPartParser({
663+
'CONTENT_TYPE': 'multipart/form-data; boundary=_foo',
664+
'CONTENT_LENGTH': '1',
665+
}, StringIO('x'), [], 'utf-8')
666+
for file_name in CANDIDATE_INVALID_FILE_NAMES:
667+
with self.subTest(file_name=file_name):
668+
self.assertIsNone(parser.sanitize_file_name(file_name))
669+
634670
def test_rfc2231_parsing(self):
635671
test_data = (
636672
(b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A",

tests/forms_tests/field_tests/test_filefield.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ def test_filefield_1(self):
2020
f.clean(None, '')
2121
self.assertEqual('files/test2.pdf', f.clean(None, 'files/test2.pdf'))
2222
no_file_msg = "'No file was submitted. Check the encoding type on the form.'"
23+
file = SimpleUploadedFile(None, b'')
24+
file._name = ''
2325
with self.assertRaisesMessage(ValidationError, no_file_msg):
24-
f.clean(SimpleUploadedFile('', b''))
26+
f.clean(file)
2527
with self.assertRaisesMessage(ValidationError, no_file_msg):
26-
f.clean(SimpleUploadedFile('', b''), '')
28+
f.clean(file, '')
2729
self.assertEqual('files/test3.pdf', f.clean(None, 'files/test3.pdf'))
2830
with self.assertRaisesMessage(ValidationError, no_file_msg):
2931
f.clean('some content that is not a file')

tests/utils_tests/test_text.py

+8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import sys
33

4+
from django.core.exceptions import SuspiciousFileOperation
45
from django.test import SimpleTestCase
56
from django.utils import text
67
from django.utils.functional import lazystr
@@ -229,6 +230,13 @@ def test_get_valid_filename(self):
229230
filename = "^&'@{}[],$=!-#()%+~_123.txt"
230231
self.assertEqual(text.get_valid_filename(filename), "-_123.txt")
231232
self.assertEqual(text.get_valid_filename(lazystr(filename)), "-_123.txt")
233+
msg = "Could not derive file name from '???'"
234+
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
235+
text.get_valid_filename('???')
236+
# After sanitizing this would yield '..'.
237+
msg = "Could not derive file name from '$.$.$'"
238+
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
239+
text.get_valid_filename('$.$.$')
232240

233241
def test_compress_sequence(self):
234242
data = [{'key': i} for i in range(10)]

0 commit comments

Comments
 (0)