Skip to content

Commit

Permalink
[3.2.x] Fixed CVE-2023-24580 -- Prevented DoS with too many uploaded …
Browse files Browse the repository at this point in the history
…files.

Thanks to Jakob Ackermann for the report.
  • Loading branch information
MarkusH authored and carltongibson committed Feb 7, 2023
1 parent 932b5bd commit a665ed5
Show file tree
Hide file tree
Showing 10 changed files with 184 additions and 18 deletions.
4 changes: 4 additions & 0 deletions django/conf/global_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,10 @@ def gettext_noop(s):
# SuspiciousOperation (TooManyFieldsSent) is raised.
DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000

# Maximum number of files encoded in a multipart upload that will be read
# before a SuspiciousOperation (TooManyFilesSent) is raised.
DATA_UPLOAD_MAX_NUMBER_FILES = 100

# Directory in which upload streamed files will be temporarily saved. A value of
# `None` will make Django use the operating system's default temporary directory
# (i.e. "/tmp" on *nix systems).
Expand Down
9 changes: 9 additions & 0 deletions django/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ class TooManyFieldsSent(SuspiciousOperation):
pass


class TooManyFilesSent(SuspiciousOperation):
"""
The number of fields in a GET or POST request exceeded
settings.DATA_UPLOAD_MAX_NUMBER_FILES.
"""

pass


class RequestDataTooBig(SuspiciousOperation):
"""
The size of the request (excluding any file uploads) exceeded
Expand Down
4 changes: 2 additions & 2 deletions django/core/handlers/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from django.core import signals
from django.core.exceptions import (
BadRequest, PermissionDenied, RequestDataTooBig, SuspiciousOperation,
TooManyFieldsSent,
TooManyFieldsSent, TooManyFilesSent,
)
from django.http import Http404
from django.http.multipartparser import MultiPartParserError
Expand Down Expand Up @@ -88,7 +88,7 @@ def response_for_exception(request, exc):
exc_info=sys.exc_info(),
)
elif isinstance(exc, SuspiciousOperation):
if isinstance(exc, (RequestDataTooBig, TooManyFieldsSent)):
if isinstance(exc, (RequestDataTooBig, TooManyFieldsSent, TooManyFilesSent)):
# POST data can't be accessed again, otherwise the original
# exception would be raised.
request._mark_post_parse_error()
Expand Down
62 changes: 51 additions & 11 deletions django/http/multipartparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from django.conf import settings
from django.core.exceptions import (
RequestDataTooBig, SuspiciousMultipartForm, TooManyFieldsSent,
TooManyFilesSent,
)
from django.core.files.uploadhandler import (
SkipFile, StopFutureHandlers, StopUpload,
Expand All @@ -38,6 +39,7 @@ class InputStreamExhausted(Exception):
RAW = "raw"
FILE = "file"
FIELD = "field"
FIELD_TYPES = frozenset([FIELD, RAW])


class MultiPartParser:
Expand Down Expand Up @@ -102,6 +104,22 @@ def __init__(self, META, input_data, upload_handlers, encoding=None):
self._upload_handlers = upload_handlers

def parse(self):
# Call the actual parse routine and close all open files in case of
# errors. This is needed because if exceptions are thrown the
# MultiPartParser will not be garbage collected immediately and
# resources would be kept alive. This is only needed for errors because
# the Request object closes all uploaded files at the end of the
# request.
try:
return self._parse()
except Exception:
if hasattr(self, "_files"):
for _, files in self._files.lists():
for fileobj in files:
fileobj.close()
raise

def _parse(self):
"""
Parse the POST data and break it into a FILES MultiValueDict and a POST
MultiValueDict.
Expand Down Expand Up @@ -147,6 +165,8 @@ def parse(self):
num_bytes_read = 0
# To count the number of keys in the request.
num_post_keys = 0
# To count the number of files in the request.
num_files = 0
# To limit the amount of data read from the request.
read_size = None
# Whether a file upload is finished.
Expand All @@ -162,6 +182,20 @@ def parse(self):
old_field_name = None
uploaded_file = True

if (
item_type in FIELD_TYPES and
settings.DATA_UPLOAD_MAX_NUMBER_FIELDS is not None
):
# Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FIELDS.
num_post_keys += 1
# 2 accounts for empty raw fields before and after the
# last boundary.
if settings.DATA_UPLOAD_MAX_NUMBER_FIELDS + 2 < num_post_keys:
raise TooManyFieldsSent(
"The number of GET/POST parameters exceeded "
"settings.DATA_UPLOAD_MAX_NUMBER_FIELDS."
)

try:
disposition = meta_data['content-disposition'][1]
field_name = disposition['name'].strip()
Expand All @@ -174,15 +208,6 @@ def parse(self):
field_name = force_str(field_name, encoding, errors='replace')

if item_type == FIELD:
# Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FIELDS.
num_post_keys += 1
if (settings.DATA_UPLOAD_MAX_NUMBER_FIELDS is not None and
settings.DATA_UPLOAD_MAX_NUMBER_FIELDS < num_post_keys):
raise TooManyFieldsSent(
'The number of GET/POST parameters exceeded '
'settings.DATA_UPLOAD_MAX_NUMBER_FIELDS.'
)

# Avoid reading more than DATA_UPLOAD_MAX_MEMORY_SIZE.
if settings.DATA_UPLOAD_MAX_MEMORY_SIZE is not None:
read_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE - num_bytes_read
Expand All @@ -208,6 +233,16 @@ def parse(self):

self._post.appendlist(field_name, force_str(data, encoding, errors='replace'))
elif item_type == FILE:
# Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FILES.
num_files += 1
if (
settings.DATA_UPLOAD_MAX_NUMBER_FILES is not None and
num_files > settings.DATA_UPLOAD_MAX_NUMBER_FILES
):
raise TooManyFilesSent(
"The number of files exceeded "
"settings.DATA_UPLOAD_MAX_NUMBER_FILES."
)
# This is a file, use the handler...
file_name = disposition.get('filename')
if file_name:
Expand Down Expand Up @@ -276,8 +311,13 @@ def parse(self):
# Handle file upload completions on next iteration.
old_field_name = field_name
else:
# If this is neither a FIELD or a FILE, just exhaust the stream.
exhaust(stream)
# If this is neither a FIELD nor a FILE, exhaust the field
# stream. Note: There could be an error here at some point,
# but there will be at least two RAW types (before and
# after the other boundaries). This branch is usually not
# reached at all, because a missing content-disposition
# header will skip the whole boundary.
exhaust(field_stream)
except StopUpload as e:
self._close_files()
if not e.connection_reset:
Expand Down
6 changes: 4 additions & 2 deletions django/http/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
DisallowedHost, ImproperlyConfigured, RequestDataTooBig, TooManyFieldsSent,
)
from django.core.files import uploadhandler
from django.http.multipartparser import MultiPartParser, MultiPartParserError
from django.http.multipartparser import (
MultiPartParser, MultiPartParserError, TooManyFilesSent,
)
from django.utils.datastructures import (
CaseInsensitiveMapping, ImmutableList, MultiValueDict,
)
Expand Down Expand Up @@ -360,7 +362,7 @@ def _load_post_and_files(self):
data = self
try:
self._post, self._files = self.parse_file_upload(self.META, data)
except MultiPartParserError:
except (MultiPartParserError, TooManyFilesSent):
# An error occurred while parsing POST data. Since when
# formatting the error the request handler might access
# self.POST, set self._post and self._file to prevent
Expand Down
5 changes: 5 additions & 0 deletions docs/ref/exceptions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,17 @@ Django core exception classes are defined in ``django.core.exceptions``.
* ``SuspiciousMultipartForm``
* ``SuspiciousSession``
* ``TooManyFieldsSent``
* ``TooManyFilesSent``

If a ``SuspiciousOperation`` exception reaches the ASGI/WSGI handler level
it is logged at the ``Error`` level and results in
a :class:`~django.http.HttpResponseBadRequest`. See the :doc:`logging
documentation </topics/logging/>` for more information.

.. versionchanged:: 3.2.18

``SuspiciousOperation`` is raised when too many files are submitted.

``PermissionDenied``
--------------------

Expand Down
23 changes: 23 additions & 0 deletions docs/ref/settings.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1063,6 +1063,28 @@ could be used as a denial-of-service attack vector if left unchecked. Since web
servers don't typically perform deep request inspection, it's not possible to
perform a similar check at that level.

.. setting:: DATA_UPLOAD_MAX_NUMBER_FILES

``DATA_UPLOAD_MAX_NUMBER_FILES``
--------------------------------

.. versionadded:: 3.2.18

Default: ``100``

The maximum number of files that may be received via POST in a
``multipart/form-data`` encoded request before a
:exc:`~django.core.exceptions.SuspiciousOperation` (``TooManyFiles``) is
raised. You can set this to ``None`` to disable the check. Applications that
are expected to receive an unusually large number of file fields should tune
this setting.

The number of accepted files is correlated to the amount of time and memory
needed to process the request. Large requests could be used as a
denial-of-service attack vector if left unchecked. Since web servers don't
typically perform deep request inspection, it's not possible to perform a
similar check at that level.

.. setting:: DATABASE_ROUTERS

``DATABASE_ROUTERS``
Expand Down Expand Up @@ -3671,6 +3693,7 @@ HTTP
----
* :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE`
* :setting:`DATA_UPLOAD_MAX_NUMBER_FIELDS`
* :setting:`DATA_UPLOAD_MAX_NUMBER_FILES`
* :setting:`DEFAULT_CHARSET`
* :setting:`DISALLOWED_USER_AGENTS`
* :setting:`FORCE_SCRIPT_NAME`
Expand Down
10 changes: 9 additions & 1 deletion docs/releases/3.2.18.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,12 @@ Django 3.2.18 release notes

Django 3.2.18 fixes a security issue with severity "moderate" in 3.2.17.

...
CVE-2023-24580: Potential denial-of-service vulnerability in file uploads
=========================================================================

Passing certain inputs to multipart forms could result in too many open files
or memory exhaustion, and provided a potential vector for a denial-of-service
attack.

The number of files parts parsed is now limited via the new
:setting:`DATA_UPLOAD_MAX_NUMBER_FILES` setting.
28 changes: 27 additions & 1 deletion tests/handlers/test_exception.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from django.core.handlers.wsgi import WSGIHandler
from django.test import SimpleTestCase, override_settings
from django.test.client import FakePayload
from django.test.client import (
BOUNDARY, MULTIPART_CONTENT, FakePayload, encode_multipart,
)


class ExceptionHandlerTests(SimpleTestCase):
Expand All @@ -25,3 +27,27 @@ def test_data_upload_max_memory_size_exceeded(self):
def test_data_upload_max_number_fields_exceeded(self):
response = WSGIHandler()(self.get_suspicious_environ(), lambda *a, **k: None)
self.assertEqual(response.status_code, 400)

@override_settings(DATA_UPLOAD_MAX_NUMBER_FILES=2)
def test_data_upload_max_number_files_exceeded(self):
payload = FakePayload(
encode_multipart(
BOUNDARY,
{
"a.txt": "Hello World!",
"b.txt": "Hello Django!",
"c.txt": "Hello Python!",
},
)
)
environ = {
"REQUEST_METHOD": "POST",
"CONTENT_TYPE": MULTIPART_CONTENT,
"CONTENT_LENGTH": len(payload),
"wsgi.input": payload,
"SERVER_NAME": "test",
"SERVER_PORT": "8000",
}

response = WSGIHandler()(environ, lambda *a, **k: None)
self.assertEqual(response.status_code, 400)
51 changes: 50 additions & 1 deletion tests/requests/test_data_upload_settings.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from io import BytesIO

from django.core.exceptions import RequestDataTooBig, TooManyFieldsSent
from django.core.exceptions import (
RequestDataTooBig, TooManyFieldsSent, TooManyFilesSent,
)
from django.core.handlers.wsgi import WSGIRequest
from django.test import SimpleTestCase
from django.test.client import FakePayload

TOO_MANY_FIELDS_MSG = 'The number of GET/POST parameters exceeded settings.DATA_UPLOAD_MAX_NUMBER_FIELDS.'
TOO_MANY_FILES_MSG = 'The number of files exceeded settings.DATA_UPLOAD_MAX_NUMBER_FILES.'
TOO_MUCH_DATA_MSG = 'Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE.'


Expand Down Expand Up @@ -166,6 +169,52 @@ def test_no_limit(self):
self.request._load_post_and_files()


class DataUploadMaxNumberOfFilesMultipartPost(SimpleTestCase):
def setUp(self):
payload = FakePayload(
"\r\n".join(
[
"--boundary",
(
'Content-Disposition: form-data; name="name1"; '
'filename="name1.txt"'
),
"",
"value1",
"--boundary",
(
'Content-Disposition: form-data; name="name2"; '
'filename="name2.txt"'
),
"",
"value2",
"--boundary--",
]
)
)
self.request = WSGIRequest(
{
"REQUEST_METHOD": "POST",
"CONTENT_TYPE": "multipart/form-data; boundary=boundary",
"CONTENT_LENGTH": len(payload),
"wsgi.input": payload,
}
)

def test_number_exceeded(self):
with self.settings(DATA_UPLOAD_MAX_NUMBER_FILES=1):
with self.assertRaisesMessage(TooManyFilesSent, TOO_MANY_FILES_MSG):
self.request._load_post_and_files()

def test_number_not_exceeded(self):
with self.settings(DATA_UPLOAD_MAX_NUMBER_FILES=2):
self.request._load_post_and_files()

def test_no_limit(self):
with self.settings(DATA_UPLOAD_MAX_NUMBER_FILES=None):
self.request._load_post_and_files()


class DataUploadMaxNumberOfFieldsFormPost(SimpleTestCase):
def setUp(self):
payload = FakePayload("\r\n".join(['a=1&a=2&a=3', '']))
Expand Down

0 comments on commit a665ed5

Please sign in to comment.