Skip to content

Commit b556999

Browse files
authored
Fixed #32718 -- Relaxed file name validation in FileField.
- Validate filename returned by FileField.upload_to() not a filename passed to the FileField.generate_filename() (upload_to() may completely ignored passed filename). - Allow relative paths (without dot segments) in the generated filename. Thanks to Jakub Kleň for the report and review. Thanks to all folks for checking this patch on existing projects. Thanks Florian Apolloner and Markus Holtermann for the discussion and implementation idea. Regression in 0b79eb3.
1 parent b81c756 commit b556999

File tree

8 files changed

+140
-17
lines changed

8 files changed

+140
-17
lines changed

django/core/files/utils.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
import os
2+
import pathlib
23

34
from django.core.exceptions import SuspiciousFileOperation
45

56

6-
def validate_file_name(name):
7-
if name != os.path.basename(name):
8-
raise SuspiciousFileOperation("File name '%s' includes path elements" % name)
9-
7+
def validate_file_name(name, allow_relative_path=False):
108
# Remove potentially dangerous names
11-
if name in {'', '.', '..'}:
9+
if os.path.basename(name) in {'', '.', '..'}:
1210
raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
1311

12+
if allow_relative_path:
13+
# Use PurePosixPath() because this branch is checked only in
14+
# FileField.generate_filename() where all file paths are expected to be
15+
# Unix style (with forward slashes).
16+
path = pathlib.PurePosixPath(name)
17+
if path.is_absolute() or '..' in path.parts:
18+
raise SuspiciousFileOperation(
19+
"Detected path traversal attempt in '%s'" % name
20+
)
21+
elif name != os.path.basename(name):
22+
raise SuspiciousFileOperation("File name '%s' includes path elements" % name)
23+
1424
return name
1525

1626

django/db/models/fields/files.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,12 +313,12 @@ def generate_filename(self, instance, filename):
313313
Until the storage layer, all file paths are expected to be Unix style
314314
(with forward slashes).
315315
"""
316-
filename = validate_file_name(filename)
317316
if callable(self.upload_to):
318317
filename = self.upload_to(instance, filename)
319318
else:
320319
dirname = datetime.datetime.now().strftime(str(self.upload_to))
321320
filename = posixpath.join(dirname, filename)
321+
filename = validate_file_name(filename, allow_relative_path=True)
322322
return self.storage.generate_filename(filename)
323323

324324
def save_form_data(self, instance, data):

docs/releases/2.2.23.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
===========================
2+
Django 2.2.23 release notes
3+
===========================
4+
5+
*May 13, 2021*
6+
7+
Django 2.2.23 fixes a regression in 2.2.21.
8+
9+
Bugfixes
10+
========
11+
12+
* Fixed a regression in Django 2.2.21 where saving ``FileField`` would raise a
13+
``SuspiciousFileOperation`` even when a custom
14+
:attr:`~django.db.models.FileField.upload_to` returns a valid file path
15+
(:ticket:`32718`).

docs/releases/3.1.11.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
===========================
2+
Django 3.1.11 release notes
3+
===========================
4+
5+
*May 13, 2021*
6+
7+
Django 3.1.11 fixes a regression in 3.1.9.
8+
9+
Bugfixes
10+
========
11+
12+
* Fixed a regression in Django 3.1.9 where saving ``FileField`` would raise a
13+
``SuspiciousFileOperation`` even when a custom
14+
:attr:`~django.db.models.FileField.upload_to` returns a valid file path
15+
(:ticket:`32718`).

docs/releases/3.2.3.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Django 3.2.3 release notes
33
==========================
44

5-
*Expected June 1, 2021*
5+
*May 13, 2021*
66

77
Django 3.2.3 fixes several bugs in 3.2.2.
88

@@ -13,3 +13,8 @@ Bugfixes
1313

1414
* Fixed a regression in Django 3.2 that caused the incorrect filtering of
1515
querysets combined with the ``|`` operator (:ticket:`32717`).
16+
17+
* Fixed a regression in Django 3.2.1 where saving ``FileField`` would raise a
18+
``SuspiciousFileOperation`` even when a custom
19+
:attr:`~django.db.models.FileField.upload_to` returns a valid file path
20+
(:ticket:`32718`).

docs/releases/index.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ versions of the documentation contain the release notes for any later releases.
4242
.. toctree::
4343
:maxdepth: 1
4444

45+
3.1.11
4546
3.1.10
4647
3.1.9
4748
3.1.8
@@ -80,6 +81,7 @@ versions of the documentation contain the release notes for any later releases.
8081
.. toctree::
8182
:maxdepth: 1
8283

84+
2.2.23
8385
2.2.22
8486
2.2.21
8587
2.2.20

tests/file_storage/test_generate_filename.py

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import os
2-
import sys
3-
from unittest import skipIf
42

53
from django.core.exceptions import SuspiciousFileOperation
64
from django.core.files.base import ContentFile
@@ -64,19 +62,37 @@ def test_storage_dangerous_paths_dir_name(self):
6462
s.generate_filename(file_name)
6563

6664
def test_filefield_dangerous_filename(self):
67-
candidates = ['..', '.', '', '???', '$.$.$']
65+
candidates = [
66+
('..', 'some/folder/..'),
67+
('.', 'some/folder/.'),
68+
('', 'some/folder/'),
69+
('???', '???'),
70+
('$.$.$', '$.$.$'),
71+
]
6872
f = FileField(upload_to='some/folder/')
69-
msg = "Could not derive file name from '%s'"
70-
for file_name in candidates:
73+
for file_name, msg_file_name in candidates:
74+
msg = f"Could not derive file name from '{msg_file_name}'"
7175
with self.subTest(file_name=file_name):
72-
with self.assertRaisesMessage(SuspiciousFileOperation, msg % file_name):
76+
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
7377
f.generate_filename(None, file_name)
7478

75-
def test_filefield_dangerous_filename_dir(self):
79+
def test_filefield_dangerous_filename_dot_segments(self):
7680
f = FileField(upload_to='some/folder/')
77-
msg = "File name '/tmp/path' includes path elements"
81+
msg = "Detected path traversal attempt in 'some/folder/../path'"
7882
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
79-
f.generate_filename(None, '/tmp/path')
83+
f.generate_filename(None, '../path')
84+
85+
def test_filefield_generate_filename_absolute_path(self):
86+
f = FileField(upload_to='some/folder/')
87+
candidates = [
88+
'/tmp/path',
89+
'/tmp/../path',
90+
]
91+
for file_name in candidates:
92+
msg = f"Detected path traversal attempt in '{file_name}'"
93+
with self.subTest(file_name=file_name):
94+
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
95+
f.generate_filename(None, file_name)
8096

8197
def test_filefield_generate_filename(self):
8298
f = FileField(upload_to='some/folder/')
@@ -95,7 +111,57 @@ def upload_to(instance, filename):
95111
os.path.normpath('some/folder/test_with_space.txt')
96112
)
97113

98-
@skipIf(sys.platform == 'win32', 'Path components in filename are not supported after 0b79eb3.')
114+
def test_filefield_generate_filename_upload_to_overrides_dangerous_filename(self):
115+
def upload_to(instance, filename):
116+
return 'test.txt'
117+
118+
f = FileField(upload_to=upload_to)
119+
candidates = [
120+
'/tmp/.',
121+
'/tmp/..',
122+
'/tmp/../path',
123+
'/tmp/path',
124+
'some/folder/',
125+
'some/folder/.',
126+
'some/folder/..',
127+
'some/folder/???',
128+
'some/folder/$.$.$',
129+
'some/../test.txt',
130+
'',
131+
]
132+
for file_name in candidates:
133+
with self.subTest(file_name=file_name):
134+
self.assertEqual(f.generate_filename(None, file_name), 'test.txt')
135+
136+
def test_filefield_generate_filename_upload_to_absolute_path(self):
137+
def upload_to(instance, filename):
138+
return '/tmp/' + filename
139+
140+
f = FileField(upload_to=upload_to)
141+
candidates = [
142+
'path',
143+
'../path',
144+
'???',
145+
'$.$.$',
146+
]
147+
for file_name in candidates:
148+
msg = f"Detected path traversal attempt in '/tmp/{file_name}'"
149+
with self.subTest(file_name=file_name):
150+
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
151+
f.generate_filename(None, file_name)
152+
153+
def test_filefield_generate_filename_upload_to_dangerous_filename(self):
154+
def upload_to(instance, filename):
155+
return '/tmp/' + filename
156+
157+
f = FileField(upload_to=upload_to)
158+
candidates = ['..', '.', '']
159+
for file_name in candidates:
160+
msg = f"Could not derive file name from '/tmp/{file_name}'"
161+
with self.subTest(file_name=file_name):
162+
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
163+
f.generate_filename(None, file_name)
164+
99165
def test_filefield_awss3_storage(self):
100166
"""
101167
Simulate a FileField with an S3 storage which uses keys rather than

tests/model_fields/test_filefield.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import unittest
66
from pathlib import Path
77

8+
from django.core.exceptions import SuspiciousFileOperation
89
from django.core.files import File, temp
910
from django.core.files.base import ContentFile
1011
from django.core.files.uploadedfile import TemporaryUploadedFile
@@ -63,6 +64,15 @@ def test_refresh_from_db(self):
6364
d.refresh_from_db()
6465
self.assertIs(d.myfile.instance, d)
6566

67+
@unittest.skipIf(sys.platform == 'win32', "Crashes with OSError on Windows.")
68+
def test_save_without_name(self):
69+
with tempfile.NamedTemporaryFile(suffix='.txt') as tmp:
70+
document = Document.objects.create(myfile='something.txt')
71+
document.myfile = File(tmp)
72+
msg = f"Detected path traversal attempt in '{tmp.name}'"
73+
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
74+
document.save()
75+
6676
def test_defer(self):
6777
Document.objects.create(myfile='something.txt')
6878
self.assertEqual(Document.objects.defer('myfile')[0].myfile, 'something.txt')

0 commit comments

Comments
 (0)