Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 30042d4

Browse files
committed
[1.4.x] Fixed #23157 -- Removed O(n) algorithm when uploading duplicate file names.
This is a security fix. Disclosure following shortly.
1 parent c2fe731 commit 30042d4

File tree

6 files changed

+75
-28
lines changed

6 files changed

+75
-28
lines changed

django/core/files/storage.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import os
22
import errno
33
import urlparse
4-
import itertools
54
from datetime import datetime
65

76
from django.conf import settings
87
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
98
from django.core.files import locks, File
109
from django.core.files.move import file_move_safe
10+
from django.utils.crypto import get_random_string
1111
from django.utils.encoding import force_unicode, filepath_to_uri
1212
from django.utils.functional import LazyObject
1313
from django.utils.importlib import import_module
@@ -63,13 +63,12 @@ def get_available_name(self, name):
6363
"""
6464
dir_name, file_name = os.path.split(name)
6565
file_root, file_ext = os.path.splitext(file_name)
66-
# If the filename already exists, add an underscore and a number (before
67-
# the file extension, if one exists) to the filename until the generated
68-
# filename doesn't exist.
69-
count = itertools.count(1)
66+
# If the filename already exists, add an underscore and a random 7
67+
# character alphanumeric string (before the file extension, if one
68+
# exists) to the filename until the generated filename doesn't exist.
7069
while self.exists(name):
7170
# file_ext includes the dot.
72-
name = os.path.join(dir_name, "%s_%s%s" % (file_root, count.next(), file_ext))
71+
name = os.path.join(dir_name, "%s_%s%s" % (file_root, get_random_string(7), file_ext))
7372

7473
return name
7574

docs/howto/custom-file-storage.txt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,13 @@ the provided filename into account. The ``name`` argument passed to this method
8686
will have already cleaned to a filename valid for the storage system, according
8787
to the ``get_valid_name()`` method described above.
8888

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

docs/ref/files/storage.txt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Django provides two convenient ways to access the current storage class:
1818
.. function:: get_storage_class([import_path=None])
1919

2020
Returns a class or module which implements the storage API.
21-
21+
2222
When called without the ``import_path`` parameter ``get_storage_class``
2323
will return the current default storage system as defined by
2424
:setting:`DEFAULT_FILE_STORAGE`. If ``import_path`` is provided,
@@ -35,9 +35,9 @@ The FileSystemStorage Class
3535
basic file storage on a local filesystem. It inherits from
3636
:class:`~django.core.files.storage.Storage` and provides implementations
3737
for all the public methods thereof.
38-
38+
3939
.. note::
40-
40+
4141
The :class:`FileSystemStorage.delete` method will not raise
4242
raise an exception if the given file name does not exist.
4343

@@ -85,6 +85,16 @@ The Storage Class
8585
available for new content to be written to on the target storage
8686
system.
8787

88+
.. versionchanged:: 1.4.14
89+
90+
If a file with ``name`` already exists, an underscore plus a random 7
91+
character alphanumeric string is appended to the filename before the
92+
extension.
93+
94+
Previously, an underscore followed by a number (e.g. ``"_1"``, ``"_2"``,
95+
etc.) was appended to the filename until an avaible name in the
96+
destination directory was found. A malicious user could exploit this
97+
deterministic algorithm to create a denial-of-service attack.
8898

8999
.. method:: get_valid_name(name)
90100

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.).

tests/modeltests/files/tests.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
from django.core.files.base import ContentFile
99
from django.core.files.uploadedfile import SimpleUploadedFile
1010
from django.test import TestCase
11+
from django.utils import six
1112

1213
from .models import Storage, temp_storage, temp_storage_location
1314

1415

16+
FILE_SUFFIX_REGEX = '[A-Za-z0-9]{7}'
17+
18+
1519
class FileTests(TestCase):
1620
def tearDown(self):
1721
shutil.rmtree(temp_storage_location)
@@ -57,27 +61,28 @@ def test_files(self):
5761
# Save another file with the same name.
5862
obj2 = Storage()
5963
obj2.normal.save("django_test.txt", ContentFile("more content"))
60-
self.assertEqual(obj2.normal.name, "tests/django_test_1.txt")
64+
obj2_name = obj2.normal.name
65+
six.assertRegex(self, obj2_name, "tests/django_test_%s.txt" % FILE_SUFFIX_REGEX)
6166
self.assertEqual(obj2.normal.size, 12)
6267

6368
# Push the objects into the cache to make sure they pickle properly
6469
cache.set("obj1", obj1)
6570
cache.set("obj2", obj2)
66-
self.assertEqual(cache.get("obj2").normal.name, "tests/django_test_1.txt")
71+
six.assertRegex(self, cache.get("obj2").normal.name, "tests/django_test_%s.txt" % FILE_SUFFIX_REGEX)
6772

6873
# Deleting an object does not delete the file it uses.
6974
obj2.delete()
7075
obj2.normal.save("django_test.txt", ContentFile("more content"))
71-
self.assertEqual(obj2.normal.name, "tests/django_test_2.txt")
76+
self.assertNotEqual(obj2_name, obj2.normal.name)
77+
six.assertRegex(self, obj2.normal.name, "tests/django_test_%s.txt" % FILE_SUFFIX_REGEX)
7278

7379
# Multiple files with the same name get _N appended to them.
74-
objs = [Storage() for i in range(3)]
80+
objs = [Storage() for i in range(2)]
7581
for o in objs:
7682
o.normal.save("multiple_files.txt", ContentFile("Same Content"))
77-
self.assertEqual(
78-
[o.normal.name for o in objs],
79-
["tests/multiple_files.txt", "tests/multiple_files_1.txt", "tests/multiple_files_2.txt"]
80-
)
83+
names = [o.normal.name for o in objs]
84+
self.assertEqual(names[0], "tests/multiple_files.txt")
85+
six.assertRegex(self, names[1], "tests/multiple_files_%s.txt" % FILE_SUFFIX_REGEX)
8186
for o in objs:
8287
o.delete()
8388

tests/regressiontests/file_storage/tests.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from django.core.files.storage import FileSystemStorage, get_storage_class
2424
from django.core.files.uploadedfile import UploadedFile
2525
from django.test import SimpleTestCase
26-
from django.utils import unittest
26+
from django.utils import six, unittest
2727

2828
# Try to import PIL in either of the two ways it can end up installed.
2929
# Checking for the existence of Image is enough for CPython, but
@@ -37,6 +37,9 @@
3737
Image = None
3838

3939

40+
FILE_SUFFIX_REGEX = '[A-Za-z0-9]{7}'
41+
42+
4043
class GetStorageClassTests(SimpleTestCase):
4144

4245
def test_get_filesystem_storage(self):
@@ -417,10 +420,9 @@ def test_race_condition(self):
417420
self.thread.start()
418421
name = self.save_file('conflict')
419422
self.thread.join()
420-
self.assertTrue(self.storage.exists('conflict'))
421-
self.assertTrue(self.storage.exists('conflict_1'))
422-
self.storage.delete('conflict')
423-
self.storage.delete('conflict_1')
423+
files = sorted(os.listdir(self.storage_dir))
424+
self.assertEqual(files[0], 'conflict')
425+
six.assertRegex(self, files[1], 'conflict_%s' % FILE_SUFFIX_REGEX)
424426

425427
class FileStoragePermissions(unittest.TestCase):
426428
def setUp(self):
@@ -457,9 +459,10 @@ def test_directory_with_dot(self):
457459
self.storage.save('dotted.path/test', ContentFile("1"))
458460
self.storage.save('dotted.path/test', ContentFile("2"))
459461

462+
files = sorted(os.listdir(os.path.join(self.storage_dir, 'dotted.path')))
460463
self.assertFalse(os.path.exists(os.path.join(self.storage_dir, 'dotted_.path')))
461-
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/test')))
462-
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/test_1')))
464+
self.assertEqual(files[0], 'test')
465+
six.assertRegex(self, files[1], 'test_%s' % FILE_SUFFIX_REGEX)
463466

464467
def test_first_character_dot(self):
465468
"""
@@ -472,10 +475,12 @@ def test_first_character_dot(self):
472475
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/.test')))
473476
# Before 2.6, a leading dot was treated as an extension, and so
474477
# underscore gets added to beginning instead of end.
478+
files = sorted(os.listdir(os.path.join(self.storage_dir, 'dotted.path')))
479+
self.assertEqual(files[0], '.test')
475480
if sys.version_info < (2, 6):
476-
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/_1.test')))
481+
six.assertRegex(self, files[1], '_%s.test' % FILE_SUFFIX_REGEX)
477482
else:
478-
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/.test_1')))
483+
six.assertRegex(self, files[1], '.test_%s' % FILE_SUFFIX_REGEX)
479484

480485
class DimensionClosingBug(unittest.TestCase):
481486
"""

0 commit comments

Comments
 (0)