Skip to content

Commit a665ed5

Browse files
MarkusHcarltongibson
authored andcommitted
[3.2.x] Fixed CVE-2023-24580 -- Prevented DoS with too many uploaded files.
Thanks to Jakob Ackermann for the report.
1 parent 932b5bd commit a665ed5

File tree

10 files changed

+184
-18
lines changed

10 files changed

+184
-18
lines changed

django/conf/global_settings.py

+4
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,10 @@ def gettext_noop(s):
303303
# SuspiciousOperation (TooManyFieldsSent) is raised.
304304
DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000
305305

306+
# Maximum number of files encoded in a multipart upload that will be read
307+
# before a SuspiciousOperation (TooManyFilesSent) is raised.
308+
DATA_UPLOAD_MAX_NUMBER_FILES = 100
309+
306310
# Directory in which upload streamed files will be temporarily saved. A value of
307311
# `None` will make Django use the operating system's default temporary directory
308312
# (i.e. "/tmp" on *nix systems).

django/core/exceptions.py

+9
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ class TooManyFieldsSent(SuspiciousOperation):
5858
pass
5959

6060

61+
class TooManyFilesSent(SuspiciousOperation):
62+
"""
63+
The number of fields in a GET or POST request exceeded
64+
settings.DATA_UPLOAD_MAX_NUMBER_FILES.
65+
"""
66+
67+
pass
68+
69+
6170
class RequestDataTooBig(SuspiciousOperation):
6271
"""
6372
The size of the request (excluding any file uploads) exceeded

django/core/handlers/exception.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from django.core import signals
1010
from django.core.exceptions import (
1111
BadRequest, PermissionDenied, RequestDataTooBig, SuspiciousOperation,
12-
TooManyFieldsSent,
12+
TooManyFieldsSent, TooManyFilesSent,
1313
)
1414
from django.http import Http404
1515
from django.http.multipartparser import MultiPartParserError
@@ -88,7 +88,7 @@ def response_for_exception(request, exc):
8888
exc_info=sys.exc_info(),
8989
)
9090
elif isinstance(exc, SuspiciousOperation):
91-
if isinstance(exc, (RequestDataTooBig, TooManyFieldsSent)):
91+
if isinstance(exc, (RequestDataTooBig, TooManyFieldsSent, TooManyFilesSent)):
9292
# POST data can't be accessed again, otherwise the original
9393
# exception would be raised.
9494
request._mark_post_parse_error()

django/http/multipartparser.py

+51-11
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from django.conf import settings
1515
from django.core.exceptions import (
1616
RequestDataTooBig, SuspiciousMultipartForm, TooManyFieldsSent,
17+
TooManyFilesSent,
1718
)
1819
from django.core.files.uploadhandler import (
1920
SkipFile, StopFutureHandlers, StopUpload,
@@ -38,6 +39,7 @@ class InputStreamExhausted(Exception):
3839
RAW = "raw"
3940
FILE = "file"
4041
FIELD = "field"
42+
FIELD_TYPES = frozenset([FIELD, RAW])
4143

4244

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

104106
def parse(self):
107+
# Call the actual parse routine and close all open files in case of
108+
# errors. This is needed because if exceptions are thrown the
109+
# MultiPartParser will not be garbage collected immediately and
110+
# resources would be kept alive. This is only needed for errors because
111+
# the Request object closes all uploaded files at the end of the
112+
# request.
113+
try:
114+
return self._parse()
115+
except Exception:
116+
if hasattr(self, "_files"):
117+
for _, files in self._files.lists():
118+
for fileobj in files:
119+
fileobj.close()
120+
raise
121+
122+
def _parse(self):
105123
"""
106124
Parse the POST data and break it into a FILES MultiValueDict and a POST
107125
MultiValueDict.
@@ -147,6 +165,8 @@ def parse(self):
147165
num_bytes_read = 0
148166
# To count the number of keys in the request.
149167
num_post_keys = 0
168+
# To count the number of files in the request.
169+
num_files = 0
150170
# To limit the amount of data read from the request.
151171
read_size = None
152172
# Whether a file upload is finished.
@@ -162,6 +182,20 @@ def parse(self):
162182
old_field_name = None
163183
uploaded_file = True
164184

185+
if (
186+
item_type in FIELD_TYPES and
187+
settings.DATA_UPLOAD_MAX_NUMBER_FIELDS is not None
188+
):
189+
# Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FIELDS.
190+
num_post_keys += 1
191+
# 2 accounts for empty raw fields before and after the
192+
# last boundary.
193+
if settings.DATA_UPLOAD_MAX_NUMBER_FIELDS + 2 < num_post_keys:
194+
raise TooManyFieldsSent(
195+
"The number of GET/POST parameters exceeded "
196+
"settings.DATA_UPLOAD_MAX_NUMBER_FIELDS."
197+
)
198+
165199
try:
166200
disposition = meta_data['content-disposition'][1]
167201
field_name = disposition['name'].strip()
@@ -174,15 +208,6 @@ def parse(self):
174208
field_name = force_str(field_name, encoding, errors='replace')
175209

176210
if item_type == FIELD:
177-
# Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FIELDS.
178-
num_post_keys += 1
179-
if (settings.DATA_UPLOAD_MAX_NUMBER_FIELDS is not None and
180-
settings.DATA_UPLOAD_MAX_NUMBER_FIELDS < num_post_keys):
181-
raise TooManyFieldsSent(
182-
'The number of GET/POST parameters exceeded '
183-
'settings.DATA_UPLOAD_MAX_NUMBER_FIELDS.'
184-
)
185-
186211
# Avoid reading more than DATA_UPLOAD_MAX_MEMORY_SIZE.
187212
if settings.DATA_UPLOAD_MAX_MEMORY_SIZE is not None:
188213
read_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE - num_bytes_read
@@ -208,6 +233,16 @@ def parse(self):
208233

209234
self._post.appendlist(field_name, force_str(data, encoding, errors='replace'))
210235
elif item_type == FILE:
236+
# Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FILES.
237+
num_files += 1
238+
if (
239+
settings.DATA_UPLOAD_MAX_NUMBER_FILES is not None and
240+
num_files > settings.DATA_UPLOAD_MAX_NUMBER_FILES
241+
):
242+
raise TooManyFilesSent(
243+
"The number of files exceeded "
244+
"settings.DATA_UPLOAD_MAX_NUMBER_FILES."
245+
)
211246
# This is a file, use the handler...
212247
file_name = disposition.get('filename')
213248
if file_name:
@@ -276,8 +311,13 @@ def parse(self):
276311
# Handle file upload completions on next iteration.
277312
old_field_name = field_name
278313
else:
279-
# If this is neither a FIELD or a FILE, just exhaust the stream.
280-
exhaust(stream)
314+
# If this is neither a FIELD nor a FILE, exhaust the field
315+
# stream. Note: There could be an error here at some point,
316+
# but there will be at least two RAW types (before and
317+
# after the other boundaries). This branch is usually not
318+
# reached at all, because a missing content-disposition
319+
# header will skip the whole boundary.
320+
exhaust(field_stream)
281321
except StopUpload as e:
282322
self._close_files()
283323
if not e.connection_reset:

django/http/request.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
DisallowedHost, ImproperlyConfigured, RequestDataTooBig, TooManyFieldsSent,
1313
)
1414
from django.core.files import uploadhandler
15-
from django.http.multipartparser import MultiPartParser, MultiPartParserError
15+
from django.http.multipartparser import (
16+
MultiPartParser, MultiPartParserError, TooManyFilesSent,
17+
)
1618
from django.utils.datastructures import (
1719
CaseInsensitiveMapping, ImmutableList, MultiValueDict,
1820
)
@@ -360,7 +362,7 @@ def _load_post_and_files(self):
360362
data = self
361363
try:
362364
self._post, self._files = self.parse_file_upload(self.META, data)
363-
except MultiPartParserError:
365+
except (MultiPartParserError, TooManyFilesSent):
364366
# An error occurred while parsing POST data. Since when
365367
# formatting the error the request handler might access
366368
# self.POST, set self._post and self._file to prevent

docs/ref/exceptions.txt

+5
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,17 @@ Django core exception classes are defined in ``django.core.exceptions``.
8484
* ``SuspiciousMultipartForm``
8585
* ``SuspiciousSession``
8686
* ``TooManyFieldsSent``
87+
* ``TooManyFilesSent``
8788

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

94+
.. versionchanged:: 3.2.18
95+
96+
``SuspiciousOperation`` is raised when too many files are submitted.
97+
9398
``PermissionDenied``
9499
--------------------
95100

docs/ref/settings.txt

+23
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,28 @@ could be used as a denial-of-service attack vector if left unchecked. Since web
10631063
servers don't typically perform deep request inspection, it's not possible to
10641064
perform a similar check at that level.
10651065

1066+
.. setting:: DATA_UPLOAD_MAX_NUMBER_FILES
1067+
1068+
``DATA_UPLOAD_MAX_NUMBER_FILES``
1069+
--------------------------------
1070+
1071+
.. versionadded:: 3.2.18
1072+
1073+
Default: ``100``
1074+
1075+
The maximum number of files that may be received via POST in a
1076+
``multipart/form-data`` encoded request before a
1077+
:exc:`~django.core.exceptions.SuspiciousOperation` (``TooManyFiles``) is
1078+
raised. You can set this to ``None`` to disable the check. Applications that
1079+
are expected to receive an unusually large number of file fields should tune
1080+
this setting.
1081+
1082+
The number of accepted files is correlated to the amount of time and memory
1083+
needed to process the request. Large requests could be used as a
1084+
denial-of-service attack vector if left unchecked. Since web servers don't
1085+
typically perform deep request inspection, it's not possible to perform a
1086+
similar check at that level.
1087+
10661088
.. setting:: DATABASE_ROUTERS
10671089

10681090
``DATABASE_ROUTERS``
@@ -3671,6 +3693,7 @@ HTTP
36713693
----
36723694
* :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE`
36733695
* :setting:`DATA_UPLOAD_MAX_NUMBER_FIELDS`
3696+
* :setting:`DATA_UPLOAD_MAX_NUMBER_FILES`
36743697
* :setting:`DEFAULT_CHARSET`
36753698
* :setting:`DISALLOWED_USER_AGENTS`
36763699
* :setting:`FORCE_SCRIPT_NAME`

docs/releases/3.2.18.txt

+9-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,12 @@ Django 3.2.18 release notes
66

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

9-
...
9+
CVE-2023-24580: Potential denial-of-service vulnerability in file uploads
10+
=========================================================================
11+
12+
Passing certain inputs to multipart forms could result in too many open files
13+
or memory exhaustion, and provided a potential vector for a denial-of-service
14+
attack.
15+
16+
The number of files parts parsed is now limited via the new
17+
:setting:`DATA_UPLOAD_MAX_NUMBER_FILES` setting.

tests/handlers/test_exception.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from django.core.handlers.wsgi import WSGIHandler
22
from django.test import SimpleTestCase, override_settings
3-
from django.test.client import FakePayload
3+
from django.test.client import (
4+
BOUNDARY, MULTIPART_CONTENT, FakePayload, encode_multipart,
5+
)
46

57

68
class ExceptionHandlerTests(SimpleTestCase):
@@ -25,3 +27,27 @@ def test_data_upload_max_memory_size_exceeded(self):
2527
def test_data_upload_max_number_fields_exceeded(self):
2628
response = WSGIHandler()(self.get_suspicious_environ(), lambda *a, **k: None)
2729
self.assertEqual(response.status_code, 400)
30+
31+
@override_settings(DATA_UPLOAD_MAX_NUMBER_FILES=2)
32+
def test_data_upload_max_number_files_exceeded(self):
33+
payload = FakePayload(
34+
encode_multipart(
35+
BOUNDARY,
36+
{
37+
"a.txt": "Hello World!",
38+
"b.txt": "Hello Django!",
39+
"c.txt": "Hello Python!",
40+
},
41+
)
42+
)
43+
environ = {
44+
"REQUEST_METHOD": "POST",
45+
"CONTENT_TYPE": MULTIPART_CONTENT,
46+
"CONTENT_LENGTH": len(payload),
47+
"wsgi.input": payload,
48+
"SERVER_NAME": "test",
49+
"SERVER_PORT": "8000",
50+
}
51+
52+
response = WSGIHandler()(environ, lambda *a, **k: None)
53+
self.assertEqual(response.status_code, 400)

tests/requests/test_data_upload_settings.py

+50-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from io import BytesIO
22

3-
from django.core.exceptions import RequestDataTooBig, TooManyFieldsSent
3+
from django.core.exceptions import (
4+
RequestDataTooBig, TooManyFieldsSent, TooManyFilesSent,
5+
)
46
from django.core.handlers.wsgi import WSGIRequest
57
from django.test import SimpleTestCase
68
from django.test.client import FakePayload
79

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

1114

@@ -166,6 +169,52 @@ def test_no_limit(self):
166169
self.request._load_post_and_files()
167170

168171

172+
class DataUploadMaxNumberOfFilesMultipartPost(SimpleTestCase):
173+
def setUp(self):
174+
payload = FakePayload(
175+
"\r\n".join(
176+
[
177+
"--boundary",
178+
(
179+
'Content-Disposition: form-data; name="name1"; '
180+
'filename="name1.txt"'
181+
),
182+
"",
183+
"value1",
184+
"--boundary",
185+
(
186+
'Content-Disposition: form-data; name="name2"; '
187+
'filename="name2.txt"'
188+
),
189+
"",
190+
"value2",
191+
"--boundary--",
192+
]
193+
)
194+
)
195+
self.request = WSGIRequest(
196+
{
197+
"REQUEST_METHOD": "POST",
198+
"CONTENT_TYPE": "multipart/form-data; boundary=boundary",
199+
"CONTENT_LENGTH": len(payload),
200+
"wsgi.input": payload,
201+
}
202+
)
203+
204+
def test_number_exceeded(self):
205+
with self.settings(DATA_UPLOAD_MAX_NUMBER_FILES=1):
206+
with self.assertRaisesMessage(TooManyFilesSent, TOO_MANY_FILES_MSG):
207+
self.request._load_post_and_files()
208+
209+
def test_number_not_exceeded(self):
210+
with self.settings(DATA_UPLOAD_MAX_NUMBER_FILES=2):
211+
self.request._load_post_and_files()
212+
213+
def test_no_limit(self):
214+
with self.settings(DATA_UPLOAD_MAX_NUMBER_FILES=None):
215+
self.request._load_post_and_files()
216+
217+
169218
class DataUploadMaxNumberOfFieldsFormPost(SimpleTestCase):
170219
def setUp(self):
171220
payload = FakePayload("\r\n".join(['a=1&a=2&a=3', '']))

0 commit comments

Comments
 (0)