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

Skip to content

Commit 83f1ea8

Browse files
MarkusHcarltongibson
authored andcommitted
[4.0.x] Fixed CVE-2023-24580 -- Prevented DoS with too many uploaded files.
Thanks to Jakob Ackermann for the report.
1 parent e5aecde commit 83f1ea8

File tree

11 files changed

+202
-20
lines changed

11 files changed

+202
-20
lines changed

django/conf/global_settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,10 @@ def gettext_noop(s):
309309
# SuspiciousOperation (TooManyFieldsSent) is raised.
310310
DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000
311311

312+
# Maximum number of files encoded in a multipart upload that will be read
313+
# before a SuspiciousOperation (TooManyFilesSent) is raised.
314+
DATA_UPLOAD_MAX_NUMBER_FILES = 100
315+
312316
# Directory in which upload streamed files will be temporarily saved. A value of
313317
# `None` will make Django use the operating system's default temporary directory
314318
# (i.e. "/tmp" on *nix systems).

django/core/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ class TooManyFieldsSent(SuspiciousOperation):
6767
pass
6868

6969

70+
class TooManyFilesSent(SuspiciousOperation):
71+
"""
72+
The number of fields in a GET or POST request exceeded
73+
settings.DATA_UPLOAD_MAX_NUMBER_FILES.
74+
"""
75+
76+
pass
77+
78+
7079
class RequestDataTooBig(SuspiciousOperation):
7180
"""
7281
The size of the request (excluding any file uploads) exceeded

django/core/handlers/exception.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
RequestDataTooBig,
1414
SuspiciousOperation,
1515
TooManyFieldsSent,
16+
TooManyFilesSent,
1617
)
1718
from django.http import Http404
1819
from django.http.multipartparser import MultiPartParserError
@@ -111,7 +112,7 @@ def response_for_exception(request, exc):
111112
exc_info=sys.exc_info(),
112113
)
113114
elif isinstance(exc, SuspiciousOperation):
114-
if isinstance(exc, (RequestDataTooBig, TooManyFieldsSent)):
115+
if isinstance(exc, (RequestDataTooBig, TooManyFieldsSent, TooManyFilesSent)):
115116
# POST data can't be accessed again, otherwise the original
116117
# exception would be raised.
117118
request._mark_post_parse_error()

django/http/multipartparser.py

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
RequestDataTooBig,
1717
SuspiciousMultipartForm,
1818
TooManyFieldsSent,
19+
TooManyFilesSent,
1920
)
2021
from django.core.files.uploadhandler import SkipFile, StopFutureHandlers, StopUpload
2122
from django.utils.datastructures import MultiValueDict
@@ -39,6 +40,7 @@ class InputStreamExhausted(Exception):
3940
RAW = "raw"
4041
FILE = "file"
4142
FIELD = "field"
43+
FIELD_TYPES = frozenset([FIELD, RAW])
4244

4345

4446
class MultiPartParser:
@@ -109,6 +111,22 @@ def __init__(self, META, input_data, upload_handlers, encoding=None):
109111
self._upload_handlers = upload_handlers
110112

111113
def parse(self):
114+
# Call the actual parse routine and close all open files in case of
115+
# errors. This is needed because if exceptions are thrown the
116+
# MultiPartParser will not be garbage collected immediately and
117+
# resources would be kept alive. This is only needed for errors because
118+
# the Request object closes all uploaded files at the end of the
119+
# request.
120+
try:
121+
return self._parse()
122+
except Exception:
123+
if hasattr(self, "_files"):
124+
for _, files in self._files.lists():
125+
for fileobj in files:
126+
fileobj.close()
127+
raise
128+
129+
def _parse(self):
112130
"""
113131
Parse the POST data and break it into a FILES MultiValueDict and a POST
114132
MultiValueDict.
@@ -154,6 +172,8 @@ def parse(self):
154172
num_bytes_read = 0
155173
# To count the number of keys in the request.
156174
num_post_keys = 0
175+
# To count the number of files in the request.
176+
num_files = 0
157177
# To limit the amount of data read from the request.
158178
read_size = None
159179
# Whether a file upload is finished.
@@ -169,6 +189,20 @@ def parse(self):
169189
old_field_name = None
170190
uploaded_file = True
171191

192+
if (
193+
item_type in FIELD_TYPES
194+
and settings.DATA_UPLOAD_MAX_NUMBER_FIELDS is not None
195+
):
196+
# Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FIELDS.
197+
num_post_keys += 1
198+
# 2 accounts for empty raw fields before and after the
199+
# last boundary.
200+
if settings.DATA_UPLOAD_MAX_NUMBER_FIELDS + 2 < num_post_keys:
201+
raise TooManyFieldsSent(
202+
"The number of GET/POST parameters exceeded "
203+
"settings.DATA_UPLOAD_MAX_NUMBER_FIELDS."
204+
)
205+
172206
try:
173207
disposition = meta_data["content-disposition"][1]
174208
field_name = disposition["name"].strip()
@@ -181,17 +215,6 @@ def parse(self):
181215
field_name = force_str(field_name, encoding, errors="replace")
182216

183217
if item_type == FIELD:
184-
# Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FIELDS.
185-
num_post_keys += 1
186-
if (
187-
settings.DATA_UPLOAD_MAX_NUMBER_FIELDS is not None
188-
and settings.DATA_UPLOAD_MAX_NUMBER_FIELDS < num_post_keys
189-
):
190-
raise TooManyFieldsSent(
191-
"The number of GET/POST parameters exceeded "
192-
"settings.DATA_UPLOAD_MAX_NUMBER_FIELDS."
193-
)
194-
195218
# Avoid reading more than DATA_UPLOAD_MAX_MEMORY_SIZE.
196219
if settings.DATA_UPLOAD_MAX_MEMORY_SIZE is not None:
197220
read_size = (
@@ -226,6 +249,16 @@ def parse(self):
226249
field_name, force_str(data, encoding, errors="replace")
227250
)
228251
elif item_type == FILE:
252+
# Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FILES.
253+
num_files += 1
254+
if (
255+
settings.DATA_UPLOAD_MAX_NUMBER_FILES is not None
256+
and num_files > settings.DATA_UPLOAD_MAX_NUMBER_FILES
257+
):
258+
raise TooManyFilesSent(
259+
"The number of files exceeded "
260+
"settings.DATA_UPLOAD_MAX_NUMBER_FILES."
261+
)
229262
# This is a file, use the handler...
230263
file_name = disposition.get("filename")
231264
if file_name:
@@ -303,8 +336,13 @@ def parse(self):
303336
# Handle file upload completions on next iteration.
304337
old_field_name = field_name
305338
else:
306-
# If this is neither a FIELD or a FILE, just exhaust the stream.
307-
exhaust(stream)
339+
# If this is neither a FIELD nor a FILE, exhaust the field
340+
# stream. Note: There could be an error here at some point,
341+
# but there will be at least two RAW types (before and
342+
# after the other boundaries). This branch is usually not
343+
# reached at all, because a missing content-disposition
344+
# header will skip the whole boundary.
345+
exhaust(field_stream)
308346
except StopUpload as e:
309347
self._close_files()
310348
if not e.connection_reset:

django/http/request.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
TooManyFieldsSent,
1515
)
1616
from django.core.files import uploadhandler
17-
from django.http.multipartparser import MultiPartParser, MultiPartParserError
17+
from django.http.multipartparser import (
18+
MultiPartParser,
19+
MultiPartParserError,
20+
TooManyFilesSent,
21+
)
1822
from django.utils.datastructures import (
1923
CaseInsensitiveMapping,
2024
ImmutableList,
@@ -367,7 +371,7 @@ def _load_post_and_files(self):
367371
data = self
368372
try:
369373
self._post, self._files = self.parse_file_upload(self.META, data)
370-
except MultiPartParserError:
374+
except (MultiPartParserError, TooManyFilesSent):
371375
# An error occurred while parsing POST data. Since when
372376
# formatting the error the request handler might access
373377
# self.POST, set self._post and self._file to prevent

docs/ref/exceptions.txt

Lines changed: 5 additions & 0 deletions
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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,6 +1076,28 @@ could be used as a denial-of-service attack vector if left unchecked. Since web
10761076
servers don't typically perform deep request inspection, it's not possible to
10771077
perform a similar check at that level.
10781078

1079+
.. setting:: DATA_UPLOAD_MAX_NUMBER_FILES
1080+
1081+
``DATA_UPLOAD_MAX_NUMBER_FILES``
1082+
--------------------------------
1083+
1084+
.. versionadded:: 3.2.18
1085+
1086+
Default: ``100``
1087+
1088+
The maximum number of files that may be received via POST in a
1089+
``multipart/form-data`` encoded request before a
1090+
:exc:`~django.core.exceptions.SuspiciousOperation` (``TooManyFiles``) is
1091+
raised. You can set this to ``None`` to disable the check. Applications that
1092+
are expected to receive an unusually large number of file fields should tune
1093+
this setting.
1094+
1095+
The number of accepted files is correlated to the amount of time and memory
1096+
needed to process the request. Large requests could be used as a
1097+
denial-of-service attack vector if left unchecked. Since web servers don't
1098+
typically perform deep request inspection, it's not possible to perform a
1099+
similar check at that level.
1100+
10791101
.. setting:: DATABASE_ROUTERS
10801102

10811103
``DATABASE_ROUTERS``
@@ -3658,6 +3680,7 @@ HTTP
36583680
----
36593681
* :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE`
36603682
* :setting:`DATA_UPLOAD_MAX_NUMBER_FIELDS`
3683+
* :setting:`DATA_UPLOAD_MAX_NUMBER_FILES`
36613684
* :setting:`DEFAULT_CHARSET`
36623685
* :setting:`DISALLOWED_USER_AGENTS`
36633686
* :setting:`FORCE_SCRIPT_NAME`

docs/releases/3.2.18.txt

Lines changed: 9 additions & 1 deletion
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.

docs/releases/4.0.10.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,12 @@ Django 4.0.10 release notes
66

77
Django 4.0.10 fixes a security issue with severity "moderate" in 4.0.9.
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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
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,
5+
MULTIPART_CONTENT,
6+
FakePayload,
7+
encode_multipart,
8+
)
49

510

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

0 commit comments

Comments
 (0)