[1.7.x] Fixed #23157 -- Removed O(n) algorithm when uploading duplicate file names.

This is a security fix. Disclosure following shortly.
This commit is contained in:
Tim Graham 2014-08-08 10:20:08 -04:00
parent bf650a2ee7
commit 3123f8452c
8 changed files with 122 additions and 27 deletions

View File

@ -1,12 +1,12 @@
import os import os
import errno import errno
import itertools
from datetime import datetime from datetime import datetime
from django.conf import settings from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation from django.core.exceptions import SuspiciousFileOperation
from django.core.files import locks, File from django.core.files import locks, File
from django.core.files.move import file_move_safe from django.core.files.move import file_move_safe
from django.utils.crypto import get_random_string
from django.utils.encoding import force_text, filepath_to_uri from django.utils.encoding import force_text, filepath_to_uri
from django.utils.functional import LazyObject from django.utils.functional import LazyObject
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
@ -69,13 +69,12 @@ class Storage(object):
""" """
dir_name, file_name = os.path.split(name) dir_name, file_name = os.path.split(name)
file_root, file_ext = os.path.splitext(file_name) file_root, file_ext = os.path.splitext(file_name)
# If the filename already exists, add an underscore and a number (before # If the filename already exists, add an underscore and a random 7
# the file extension, if one exists) to the filename until the generated # character alphanumeric string (before the file extension, if one
# filename doesn't exist. # exists) to the filename until the generated filename doesn't exist.
count = itertools.count(1)
while self.exists(name): while self.exists(name):
# file_ext includes the dot. # file_ext includes the dot.
name = os.path.join(dir_name, "%s_%s%s" % (file_root, next(count), file_ext)) name = os.path.join(dir_name, "%s_%s%s" % (file_root, get_random_string(7), file_ext))
return name return name

View File

@ -90,5 +90,14 @@ the provided filename into account. The ``name`` argument passed to this method
will have already cleaned to a filename valid for the storage system, according will have already cleaned to a filename valid for the storage system, according
to the ``get_valid_name()`` method described above. to the ``get_valid_name()`` method described above.
The code provided on ``Storage`` simply appends ``"_1"``, ``"_2"``, etc. to the .. versionchanged:: 1.7
filename until it finds one that's available in the destination directory.
If a file with ``name`` already exists, an underscore plus a random 7
character alphanumeric string is appended to the filename before the
extension.
Previously, an underscore followed by a number (e.g. ``"_1"``, ``"_2"``,
etc.) was appended to the filename until an avaible name in the destination
directory was found. A malicious user could exploit this deterministic
algorithm to create a denial-of-service attack. This change was also made
in Django 1.6.6, 1.5.9, and 1.4.14.

View File

@ -112,6 +112,18 @@ The Storage Class
available for new content to be written to on the target storage available for new content to be written to on the target storage
system. system.
.. versionchanged:: 1.7
If a file with ``name`` already exists, an underscore plus a random 7
character alphanumeric string is appended to the filename before the
extension.
Previously, an underscore followed by a number (e.g. ``"_1"``, ``"_2"``,
etc.) was appended to the filename until an avaible name in the
destination directory was found. A malicious user could exploit this
deterministic algorithm to create a denial-of-service attack. This
change was also made in Django 1.6.6, 1.5.9, and 1.4.14.
.. method:: get_valid_name(name) .. method:: get_valid_name(name)
Returns a filename based on the ``name`` parameter that's suitable Returns a filename based on the ``name`` parameter that's suitable

View File

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

View File

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

View File

@ -19,6 +19,26 @@ To remedy this, URL reversing now ensures that no URL starts with two slashes
approach ensures that semantics stay the same, while making the URL relative to approach ensures that semantics stay the same, while making the URL relative to
the domain and not to the scheme. the domain and not to the scheme.
File upload denial-of-service
=============================
Before this release, Django's file upload handing in its default configuration
may degrade to producing a huge number of ``os.stat()`` system calls when a
duplicate filename is uploaded. Since ``stat()`` may invoke IO, this may produce
a huge data-dependent slowdown that slowly worsens over time. The net result is
that given enough time, a user with the ability to upload files can cause poor
performance in the upload handler, eventually causing it to become very slow
simply by uploading 0-byte files. At this point, even a slow network connection
and few HTTP requests would be all that is necessary to make a site unavailable.
We've remedied the issue by changing the algorithm for generating file names
if a file with the uploaded name already exists.
:meth:`Storage.get_available_name()
<django.core.files.storage.Storage.get_available_name>` now appends an
underscore plus a random 7 character alphanumeric string (e.g. ``"_x3a1gho"``),
rather than iterating through an underscore followed by a number (e.g. ``"_1"``,
``"_2"``, etc.).
Bugfixes Bugfixes
======== ========

View File

@ -590,6 +590,13 @@ File Uploads
the client. Partially uploaded files are also closed as long as they are the client. Partially uploaded files are also closed as long as they are
named ``file`` in the upload handler. named ``file`` in the upload handler.
* :meth:`Storage.get_available_name()
<django.core.files.storage.Storage.get_available_name>` now appends an
underscore plus a random 7 character alphanumeric string (e.g.
``"_x3a1gho"``), rather than iterating through an underscore followed by a
number (e.g. ``"_1"``, ``"_2"``, etc.) to prevent a denial-of-service attack.
This change was also made in the 1.6.6, 1.5.9, and 1.4.14 security releases.
Forms Forms
^^^^^ ^^^^^

View File

@ -30,6 +30,9 @@ from django.utils._os import upath
from .models import Storage, temp_storage, temp_storage_location from .models import Storage, temp_storage, temp_storage_location
FILE_SUFFIX_REGEX = '[A-Za-z0-9]{7}'
class GetStorageClassTests(SimpleTestCase): class GetStorageClassTests(SimpleTestCase):
def test_get_filesystem_storage(self): def test_get_filesystem_storage(self):
@ -457,14 +460,16 @@ class FileFieldStorageTests(unittest.TestCase):
# Save another file with the same name. # Save another file with the same name.
obj2 = Storage() obj2 = Storage()
obj2.normal.save("django_test.txt", ContentFile("more content")) obj2.normal.save("django_test.txt", ContentFile("more content"))
self.assertEqual(obj2.normal.name, "tests/django_test_1.txt") obj2_name = obj2.normal.name
six.assertRegex(self, obj2_name, "tests/django_test_%s.txt" % FILE_SUFFIX_REGEX)
self.assertEqual(obj2.normal.size, 12) self.assertEqual(obj2.normal.size, 12)
obj2.normal.close() obj2.normal.close()
# Deleting an object does not delete the file it uses. # Deleting an object does not delete the file it uses.
obj2.delete() obj2.delete()
obj2.normal.save("django_test.txt", ContentFile("more content")) obj2.normal.save("django_test.txt", ContentFile("more content"))
self.assertEqual(obj2.normal.name, "tests/django_test_2.txt") self.assertNotEqual(obj2_name, obj2.normal.name)
six.assertRegex(self, obj2.normal.name, "tests/django_test_%s.txt" % FILE_SUFFIX_REGEX)
obj2.normal.close() obj2.normal.close()
def test_filefield_read(self): def test_filefield_read(self):
@ -477,15 +482,16 @@ class FileFieldStorageTests(unittest.TestCase):
self.assertEqual(list(obj.normal.chunks(chunk_size=2)), [b"co", b"nt", b"en", b"t"]) self.assertEqual(list(obj.normal.chunks(chunk_size=2)), [b"co", b"nt", b"en", b"t"])
obj.normal.close() obj.normal.close()
def test_file_numbering(self): def test_duplicate_filename(self):
# Multiple files with the same name get _N appended to them. # Multiple files with the same name get _(7 random chars) appended to them.
objs = [Storage() for i in range(3)] objs = [Storage() for i in range(2)]
for o in objs: for o in objs:
o.normal.save("multiple_files.txt", ContentFile("Same Content")) o.normal.save("multiple_files.txt", ContentFile("Same Content"))
self.assertEqual( try:
[o.normal.name for o in objs], names = [o.normal.name for o in objs]
["tests/multiple_files.txt", "tests/multiple_files_1.txt", "tests/multiple_files_2.txt"] self.assertEqual(names[0], "tests/multiple_files.txt")
) six.assertRegex(self, names[1], "tests/multiple_files_%s.txt" % FILE_SUFFIX_REGEX)
finally:
for o in objs: for o in objs:
o.delete() o.delete()
@ -579,10 +585,9 @@ class FileSaveRaceConditionTest(unittest.TestCase):
self.thread.start() self.thread.start()
self.save_file('conflict') self.save_file('conflict')
self.thread.join() self.thread.join()
self.assertTrue(self.storage.exists('conflict')) files = sorted(os.listdir(self.storage_dir))
self.assertTrue(self.storage.exists('conflict_1')) self.assertEqual(files[0], 'conflict')
self.storage.delete('conflict') six.assertRegex(self, files[1], 'conflict_%s' % FILE_SUFFIX_REGEX)
self.storage.delete('conflict_1')
@unittest.skipIf(sys.platform.startswith('win'), "Windows only partially supports umasks and chmod.") @unittest.skipIf(sys.platform.startswith('win'), "Windows only partially supports umasks and chmod.")
@ -643,9 +648,10 @@ class FileStoragePathParsing(unittest.TestCase):
self.storage.save('dotted.path/test', ContentFile("1")) self.storage.save('dotted.path/test', ContentFile("1"))
self.storage.save('dotted.path/test', ContentFile("2")) self.storage.save('dotted.path/test', ContentFile("2"))
files = sorted(os.listdir(os.path.join(self.storage_dir, 'dotted.path')))
self.assertFalse(os.path.exists(os.path.join(self.storage_dir, 'dotted_.path'))) self.assertFalse(os.path.exists(os.path.join(self.storage_dir, 'dotted_.path')))
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/test'))) self.assertEqual(files[0], 'test')
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/test_1'))) six.assertRegex(self, files[1], 'test_%s' % FILE_SUFFIX_REGEX)
def test_first_character_dot(self): def test_first_character_dot(self):
""" """
@ -655,8 +661,10 @@ class FileStoragePathParsing(unittest.TestCase):
self.storage.save('dotted.path/.test', ContentFile("1")) self.storage.save('dotted.path/.test', ContentFile("1"))
self.storage.save('dotted.path/.test', ContentFile("2")) self.storage.save('dotted.path/.test', ContentFile("2"))
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/.test'))) files = sorted(os.listdir(os.path.join(self.storage_dir, 'dotted.path')))
self.assertTrue(os.path.exists(os.path.join(self.storage_dir, 'dotted.path/.test_1'))) self.assertFalse(os.path.exists(os.path.join(self.storage_dir, 'dotted_.path')))
self.assertEqual(files[0], '.test')
six.assertRegex(self, files[1], '.test_%s' % FILE_SUFFIX_REGEX)
class ContentFileStorageTestCase(unittest.TestCase): class ContentFileStorageTestCase(unittest.TestCase):