Skip to content

Commit 628b33a

Browse files
MarkusHcarltongibson
authored andcommitted
[4.1.x] Fixed CVE-2023-24580 -- Prevented DoS with too many uploaded files.
Thanks to Jakob Ackermann for the report.
1 parent 425c75f commit 628b33a

File tree

12 files changed

+213
-23
lines changed

12 files changed

+213
-23
lines changed

django/conf/global_settings.py

+4
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,10 @@ def gettext_noop(s):
313313
# SuspiciousOperation (TooManyFieldsSent) is raised.
314314
DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000
315315

316+
# Maximum number of files encoded in a multipart upload that will be read
317+
# before a SuspiciousOperation (TooManyFilesSent) is raised.
318+
DATA_UPLOAD_MAX_NUMBER_FILES = 100
319+
316320
# Directory in which upload streamed files will be temporarily saved. A value of
317321
# `None` will make Django use the operating system's default temporary directory
318322
# (i.e. "/tmp" on *nix systems).

django/core/exceptions.py

+9
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

+2-1
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
exception=exc,
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

+51-13
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
RequestDataTooBig,
1616
SuspiciousMultipartForm,
1717
TooManyFieldsSent,
18+
TooManyFilesSent,
1819
)
1920
from django.core.files.uploadhandler import SkipFile, StopFutureHandlers, StopUpload
2021
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:
@@ -111,6 +113,22 @@ def __init__(self, META, input_data, upload_handlers, encoding=None):
111113
self._upload_handlers = upload_handlers
112114

113115
def parse(self):
116+
# Call the actual parse routine and close all open files in case of
117+
# errors. This is needed because if exceptions are thrown the
118+
# MultiPartParser will not be garbage collected immediately and
119+
# resources would be kept alive. This is only needed for errors because
120+
# the Request object closes all uploaded files at the end of the
121+
# request.
122+
try:
123+
return self._parse()
124+
except Exception:
125+
if hasattr(self, "_files"):
126+
for _, files in self._files.lists():
127+
for fileobj in files:
128+
fileobj.close()
129+
raise
130+
131+
def _parse(self):
114132
"""
115133
Parse the POST data and break it into a FILES MultiValueDict and a POST
116134
MultiValueDict.
@@ -156,6 +174,8 @@ def parse(self):
156174
num_bytes_read = 0
157175
# To count the number of keys in the request.
158176
num_post_keys = 0
177+
# To count the number of files in the request.
178+
num_files = 0
159179
# To limit the amount of data read from the request.
160180
read_size = None
161181
# Whether a file upload is finished.
@@ -171,6 +191,20 @@ def parse(self):
171191
old_field_name = None
172192
uploaded_file = True
173193

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

185219
if item_type == FIELD:
186-
# Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FIELDS.
187-
num_post_keys += 1
188-
if (
189-
settings.DATA_UPLOAD_MAX_NUMBER_FIELDS is not None
190-
and settings.DATA_UPLOAD_MAX_NUMBER_FIELDS < num_post_keys
191-
):
192-
raise TooManyFieldsSent(
193-
"The number of GET/POST parameters exceeded "
194-
"settings.DATA_UPLOAD_MAX_NUMBER_FIELDS."
195-
)
196-
197220
# Avoid reading more than DATA_UPLOAD_MAX_MEMORY_SIZE.
198221
if settings.DATA_UPLOAD_MAX_MEMORY_SIZE is not None:
199222
read_size = (
@@ -228,6 +251,16 @@ def parse(self):
228251
field_name, force_str(data, encoding, errors="replace")
229252
)
230253
elif item_type == FILE:
254+
# Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FILES.
255+
num_files += 1
256+
if (
257+
settings.DATA_UPLOAD_MAX_NUMBER_FILES is not None
258+
and num_files > settings.DATA_UPLOAD_MAX_NUMBER_FILES
259+
):
260+
raise TooManyFilesSent(
261+
"The number of files exceeded "
262+
"settings.DATA_UPLOAD_MAX_NUMBER_FILES."
263+
)
231264
# This is a file, use the handler...
232265
file_name = disposition.get("filename")
233266
if file_name:
@@ -305,8 +338,13 @@ def parse(self):
305338
# Handle file upload completions on next iteration.
306339
old_field_name = field_name
307340
else:
308-
# If this is neither a FIELD or a FILE, just exhaust the stream.
309-
exhaust(stream)
341+
# If this is neither a FIELD nor a FILE, exhaust the field
342+
# stream. Note: There could be an error here at some point,
343+
# but there will be at least two RAW types (before and
344+
# after the other boundaries). This branch is usually not
345+
# reached at all, because a missing content-disposition
346+
# header will skip the whole boundary.
347+
exhaust(field_stream)
310348
except StopUpload as e:
311349
self._close_files()
312350
if not e.connection_reset:

django/http/request.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
TooManyFieldsSent,
1414
)
1515
from django.core.files import uploadhandler
16-
from django.http.multipartparser import MultiPartParser, MultiPartParserError
16+
from django.http.multipartparser import (
17+
MultiPartParser,
18+
MultiPartParserError,
19+
TooManyFilesSent,
20+
)
1721
from django.utils.datastructures import (
1822
CaseInsensitiveMapping,
1923
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

+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
@@ -1108,6 +1108,28 @@ could be used as a denial-of-service attack vector if left unchecked. Since web
11081108
servers don't typically perform deep request inspection, it's not possible to
11091109
perform a similar check at that level.
11101110

1111+
.. setting:: DATA_UPLOAD_MAX_NUMBER_FILES
1112+
1113+
``DATA_UPLOAD_MAX_NUMBER_FILES``
1114+
--------------------------------
1115+
1116+
.. versionadded:: 3.2.18
1117+
1118+
Default: ``100``
1119+
1120+
The maximum number of files that may be received via POST in a
1121+
``multipart/form-data`` encoded request before a
1122+
:exc:`~django.core.exceptions.SuspiciousOperation` (``TooManyFiles``) is
1123+
raised. You can set this to ``None`` to disable the check. Applications that
1124+
are expected to receive an unusually large number of file fields should tune
1125+
this setting.
1126+
1127+
The number of accepted files is correlated to the amount of time and memory
1128+
needed to process the request. Large requests could be used as a
1129+
denial-of-service attack vector if left unchecked. Since web servers don't
1130+
typically perform deep request inspection, it's not possible to perform a
1131+
similar check at that level.
1132+
11111133
.. setting:: DATABASE_ROUTERS
11121134

11131135
``DATABASE_ROUTERS``
@@ -3727,6 +3749,7 @@ HTTP
37273749
----
37283750
* :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE`
37293751
* :setting:`DATA_UPLOAD_MAX_NUMBER_FIELDS`
3752+
* :setting:`DATA_UPLOAD_MAX_NUMBER_FILES`
37303753
* :setting:`DEFAULT_CHARSET`
37313754
* :setting:`DISALLOWED_USER_AGENTS`
37323755
* :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.

docs/releases/4.0.10.txt

+9-1
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.

docs/releases/4.1.7.txt

+11-3
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@ Django 4.1.7 release notes
44

55
*February 14, 2023*
66

7-
Django 4.1.7 fixes a security issue with severity "moderate" and several bugs
8-
in 4.1.6.
7+
Django 4.1.7 fixes a security issue with severity "moderate" and a bug in
8+
4.1.6.
99

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

1220
Bugfixes
1321
========

tests/handlers/test_exception.py

+30-1
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)