티스토리 수익 글 보기

티스토리 수익 글 보기

[1.7.x] Fixed #23157 — Removed O(n) algorithm when uploading duplica… · django/django@3123f84 · GitHub
Skip to content
/ django Public

Commit 3123f84

Browse files
committed
[1.7.x] Fixed #23157 — Removed O(n) algorithm when uploading duplicate file names.
This is a security fix. Disclosure following shortly.
1 parent bf650a2 commit 3123f84

File tree

8 files changed

+122
27
lines changed

8 files changed

+122
27
lines changed

django/core/files/storage.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import os
22
import errno
3-
import itertools
43
from datetime import datetime
54

65
from django.conf import settings
76
from django.core.exceptions import SuspiciousFileOperation
87
from django.core.files import locks, File
98
from django.core.files.move import file_move_safe
9+
from django.utils.crypto import get_random_string
1010
from django.utils.encoding import force_text, filepath_to_uri
1111
from django.utils.functional import LazyObject
1212
from django.utils.module_loading import import_string
@@ -69,13 +69,12 @@ def get_available_name(self, name):
6969
"""
7070
dir_name, file_name = os.path.split(name)
7171
file_root, file_ext = os.path.splitext(file_name)
72-
# If the filename already exists, add an underscore and a number (before
73-
# the file extension, if one exists) to the filename until the generated
74-
# filename doesn't exist.
75-
count = itertools.count(1)
72+
# If the filename already exists, add an underscore and a random 7
73+
# character alphanumeric string (before the file extension, if one
74+
# exists) to the filename until the generated filename doesn't exist.
7675
while self.exists(name):
7776
# file_ext includes the dot.
78-
name = os.path.join(dir_name, "%s_%s%s" % (file_root, next(count), file_ext))
77+
name = os.path.join(dir_name, "%s_%s%s" % (file_root, get_random_string(7), file_ext))
7978

8079
return name
8180

docs/howto/custom-file-storage.txt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,14 @@ the provided filename into account. The ``name`` argument passed to this method
9090
will have already cleaned to a filename valid for the storage system, according
9191
to the ``get_valid_name()`` method described above.
9292

93-
The code provided on ``Storage`` simply appends ``"_1"``, ``"_2"``, etc. to the
94-
filename until it finds one that's available in the destination directory.
93+
.. versionchanged:: 1.7
94+
95+
If a file with ``name`` already exists, an underscore plus a random 7
96+
character alphanumeric string is appended to the filename before the
97+
extension.
98+
99+
Previously, an underscore followed by a number (e.g. ``"_1"``, ``"_2"``,
100+
etc.) was appended to the filename until an avaible name in the destination
101+
directory was found. A malicious user could exploit this deterministic
102+
algorithm to create a denial-of-service attack. This change was also made
103+
in Django 1.6.6, 1.5.9, and 1.4.14.

docs/ref/files/storage.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,18 @@ The Storage Class
112112
available for new content to be written to on the target storage
113113
system.
114114

115+
.. versionchanged:: 1.7
116+
117+
If a file with ``name`` already exists, an underscore plus a random 7
118+
character alphanumeric string is appended to the filename before the
119+
extension.
120+
121+
Previously, an underscore followed by a number (e.g. ``"_1"``, ``"_2"``,
122+
etc.) was appended to the filename until an avaible name in the
123+
destination directory was found. A malicious user could exploit this
124+
deterministic algorithm to create a denial-of-service attack. This
125+
change was also made in Django 1.6.6, 1.5.9, and 1.4.14.
126+
115127
.. method:: get_valid_name(name)
116128

117129
Returns a filename based on the ``name`` parameter that's suitable

docs/releases/1.4.14.txt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,23 @@ To remedy this, URL reversing now ensures that no URL starts with two slashes
1818
(//), replacing the second slash with its URL encoded counterpart (%2F). This
1919
approach ensures that semantics stay the same, while making the URL relative to
2020
the domain and not to the scheme.
21+
22+
File upload denial-of-service
23+
=============================
24+
25+
Before this release, Django's file upload handing in its default configuration
26+
may degrade to producing a huge number of ``os.stat()`` system calls when a
27+
duplicate filename is uploaded. Since ``stat()`` may invoke IO, this may produce
28+
a huge data-dependent slowdown that slowly worsens over time. The net result is
29+
that given enough time, a user with the ability to upload files can cause poor
30+
performance in the upload handler, eventually causing it to become very slow
31+
simply by uploading 0-byte files. At this point, even a slow network connection
32+
and few HTTP requests would be all that is necessary to make a site unavailable.
33+
34+
We've remedied the issue by changing the algorithm for generating file names
35+
if a file with the uploaded name already exists.
36+
:meth:`Storage.get_available_name()
37+
<django.core.files.storage.Storage.get_available_name>` now appends an
38+
underscore plus a random 7 character alphanumeric string (e.g. ``"_x3a1gho"``),
39+
rather than iterating through an underscore followed by a number (e.g. ``"_1"``,
40+
``"_2"``, etc.).

docs/releases/1.5.9.txt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,23 @@ To remedy this, URL reversing now ensures that no URL starts with two slashes
1818
(//), replacing the second slash with its URL encoded counterpart (%2F). This
1919
approach ensures that semantics stay the same, while making the URL relative to
2020
the domain and not to the scheme.
21+
22+
File upload denial-of-service
23+
=============================
24+
25+
Before this release, Django's file upload handing in its default configuration
26+
may degrade to producing a huge number of ``os.stat()`` system calls when a
27+
duplicate filename is uploaded. Since ``stat()`` may invoke IO, this may produce
28+
a huge data-dependent slowdown that slowly worsens over time. The net result is
29+
that given enough time, a user with the ability to upload files can cause poor
30+
performance in the upload handler, eventually causing it to become very slow
31+
simply by uploading 0-byte files. At this point, even a slow network connection
32+
and few HTTP requests would be all that is necessary to make a site unavailable.
33+
34+
We've remedied the issue by changing the algorithm for generating file names
35+
if a file with the uploaded name already exists.
36+
:meth:`Storage.get_available_name()
37+
<django.core.files.storage.Storage.get_available_name>` now appends an
38+
underscore plus a random 7 character alphanumeric string (e.g. ``"_x3a1gho"``),
39+
rather than iterating through an underscore followed by a number (e.g. ``"_1"``,
40+
``"_2"``, etc.).

docs/releases/1.6.6.txt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,26 @@ To remedy this, URL reversing now ensures that no URL starts with two slashes
1919
approach ensures that semantics stay the same, while making the URL relative to
2020
the domain and not to the scheme.
2121

22+
File upload denial-of-service
23+
=============================
24+
25+
Before this release, Django's file upload handing in its default configuration
26+
may degrade to producing a huge number of ``os.stat()`` system calls when a
27+
duplicate filename is uploaded. Since ``stat()`` may invoke IO, this may produce
28+
a huge data-dependent slowdown that slowly worsens over time. The net result is
29+
that given enough time, a user with the ability to upload files can cause poor
30+
performance in the upload handler, eventually causing it to become very slow
31+
simply by uploading 0-byte files. At this point, even a slow network connection
32+
and few HTTP requests would be all that is necessary to make a site unavailable.
33+
34+
We've remedied the issue by changing the algorithm for generating file names
35+
if a file with the uploaded name already exists.
36+
:meth:`Storage.get_available_name()
37+
<django.core.files.storage.Storage.get_available_name>` now appends an
38+
underscore plus a random 7 character alphanumeric string (e.g. ``"_x3a1gho"``),
39+
rather than iterating through an underscore followed by a number (e.g. ``"_1"``,
40+
``"_2"``, etc.).
41+
2242
Bugfixes
2343
========
2444

docs/releases/1.7.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,13 @@ File Uploads
590590
the client. Partially uploaded files are also closed as long as they are
591591
named ``file`` in the upload handler.
592592

593+
* :meth:`Storage.get_available_name()
594+
<django.core.files.storage.Storage.get_available_name>` now appends an
595+
underscore plus a random 7 character alphanumeric string (e.g.
596+
``"_x3a1gho"``), rather than iterating through an underscore followed by a
597+
number (e.g. ``"_1"``, ``"_2"``, etc.) to prevent a denial-of-service attack.
598+
This change was also made in the 1.6.6, 1.5.9, and 1.4.14 security releases.
599+
593600
Forms
594601
^^^^^
595602

tests/file_storage/tests.py

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
from .models import Storage, temp_storage, temp_storage_location
3131

3232

33+
FILE_SUFFIX_REGEX = '[A-Za-z0-9]{7}'
34+
35+
3336
class GetStorageClassTests(SimpleTestCase):
3437

3538
def test_get_filesystem_storage(self):
@@ -457,14 +460,16 @@ def test_files(self):
457460
# Save another file with the same name.
458461
obj2 = Storage()
459462
obj2.normal.save("django_test.txt", ContentFile("more content"))
460-
self.assertEqual(obj2.normal.name, "tests/django_test_1.txt")
463+
obj2_name = obj2.normal.name
464+
six.assertRegex(self, obj2_name, "tests/django_test_%s.txt" % FILE_SUFFIX_REGEX)
461465
self.assertEqual(obj2.normal.size, 12)
462466
obj2.normal.close()
463467

464468
# Deleting an object does not delete the file it uses.
465469
obj2.delete()
466470
obj2.normal.save("django_test.txt", ContentFile("more content"))
467-
self.assertEqual(obj2.normal.name, "tests/django_test_2.txt")
471+
self.assertNotEqual(obj2_name, obj2.normal.name)
472+
six.assertRegex(self, obj2.normal.name, "tests/django_test_%s.txt" % FILE_SUFFIX_REGEX)
468473
obj2.normal.close()
469474

470475
def test_filefield_read(self):
@@ -477,17 +482,18 @@ def test_filefield_read(self):
477482
self.assertEqual(list(obj.normal.chunks(chunk_size=2)), [b"co", b"nt", b"en", b"t"])
478483
obj.normal.close()
479484

480-
def test_file_numbering(self):
481-
# Multiple files with the same name get _N appended to them.
482-
objs = [Storage() for i in range(3)]
485+
def test_duplicate_filename(self):
486+
# Multiple files with the same name get _(7 random chars) appended to them.
487+
objs = [Storage() for i in range(2)]
483488
for o in objs:
484489
o.normal.save("multiple_files.txt", ContentFile("Same Content"))
485-
self.assertEqual(
486-
[o.normal.name for o in objs],
487-
["tests/multiple_files.txt", "tests/multiple_files_1.txt", "tests/multiple_files_2.txt"]
488-
)
489-
for o in objs:
490-
o.delete()
490+
try:
491+
names = [o.normal.name for o in objs]
492+
self.assertEqual(names[0], "tests/multiple_files.txt")
493+
six.assertRegex(self, names[1], "tests/multiple_files_%s.txt" % FILE_SUFFIX_REGEX)
494+
finally:
495+
for o in objs:
496+
o.delete()
491497

492498
def test_filefield_default(self):
493499
# Default values allow an object to access a single file.
@@ -579,10 +585,9 @@ def test_race_condition(self):
579585
self.thread.start()
580586
self.save_file('conflict')
581587
self.thread.join()
582-
self.assertTrue(self.storage.exists('conflict'))
583-
self.assertTrue(self.storage.exists('conflict_1'))
584-
self.storage.delete('conflict')
585-
self.storage.delete('conflict_1')
588+
files = sorted(os.listdir(self.storage_dir))
589+
self.assertEqual(files[0], 'conflict')
590+
six.assertRegex(self, files[1], 'conflict_%s' % FILE_SUFFIX_REGEX)
586591

587592

588593
@unittest.skipIf(sys.platform.startswith('win'), "Windows only partially supports umasks and chmod.")
@@ -643,9 +648,10 @@ def test_directory_with_dot(self):
643648
self.storage.save('dotted.path/test', ContentFile("1"))
644649
self.storage.save('dotted.path/test', ContentFile("2"))
645650

651+
files = sorted(os.listdir(os.path.join(self.storage_dir, 'dotted.path')))
646652
self.assertFalse(os.path.exists(os.path.join(self.storage_dir, 'dotted_.path')))
647-
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/test')))
648-
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/test_1')))
653+
self.assertEqual(files[0], 'test')
654+
six.assertRegex(self, files[1], 'test_%s' % FILE_SUFFIX_REGEX)
649655

650656
def test_first_character_dot(self):
651657
"""
@@ -655,8 +661,10 @@ def test_first_character_dot(self):
655661
self.storage.save('dotted.path/.test', ContentFile("1"))
656662
self.storage.save('dotted.path/.test', ContentFile("2"))
657663

658-
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/.test')))
659-
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/.test_1')))
664+
files = sorted(os.listdir(os.path.join(self.storage_dir, 'dotted.path')))
665+
self.assertFalse(os.path.exists(os.path.join(self.storage_dir, 'dotted_.path')))
666+
self.assertEqual(files[0], '.test')
667+
six.assertRegex(self, files[1], '.test_%s' % FILE_SUFFIX_REGEX)
660668

661669

662670
class ContentFileStorageTestCase(unittest.TestCase):

0 commit comments

Comments
 (0)