Skip to content

Commit 517cac5

Browse files
authored
Merge pull request from GHSA-xg9f-g7g7-2323
limit the maximum number of multipart form parts
2 parents cf275f4 + babc8d9 commit 517cac5

File tree

6 files changed

+60
-18
lines changed

6 files changed

+60
-18
lines changed

CHANGES.rst

+4
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ Unreleased
2121
the requested size in one ``read`` call. :issue:`2558`
2222
- A cookie header that starts with ``=`` is treated as an empty key and discarded,
2323
rather than stripping the leading ``==``.
24+
- Specify a maximum number of multipart parts, default 1000, after which a
25+
``RequestEntityTooLarge`` exception is raised on parsing. This mitigates a DoS
26+
attack where a larger number of form/file parts would result in disproportionate
27+
resource use.
2428

2529

2630
Version 2.2.2

docs/request_data.rst

+20-17
Original file line numberDiff line numberDiff line change
@@ -73,23 +73,26 @@ read the stream *or* call :meth:`~Request.get_data`.
7373
Limiting Request Data
7474
---------------------
7575

76-
To avoid being the victim of a DDOS attack you can set the maximum
77-
accepted content length and request field sizes. The :class:`Request`
78-
class has two attributes for that: :attr:`~Request.max_content_length`
79-
and :attr:`~Request.max_form_memory_size`.
80-
81-
The first one can be used to limit the total content length. For example
82-
by setting it to ``1024 * 1024 * 16`` the request won't accept more than
83-
16MB of transmitted data.
84-
85-
Because certain data can't be moved to the hard disk (regular post data)
86-
whereas temporary files can, there is a second limit you can set. The
87-
:attr:`~Request.max_form_memory_size` limits the size of `POST`
88-
transmitted form data. By setting it to ``1024 * 1024 * 2`` you can make
89-
sure that all in memory-stored fields are not more than 2MB in size.
90-
91-
This however does *not* affect in-memory stored files if the
92-
`stream_factory` used returns a in-memory file.
76+
The :class:`Request` class provides a few attributes to control how much data is
77+
processed from the request body. This can help mitigate DoS attacks that craft the
78+
request in such a way that the server uses too many resources to handle it. Each of
79+
these limits will raise a :exc:`~werkzeug.exceptions.RequestEntityTooLarge` if they are
80+
exceeded.
81+
82+
- :attr:`~Request.max_content_length` Stop reading request data after this number
83+
of bytes. It's better to configure this in the WSGI server or HTTP server, rather
84+
than the WSGI application.
85+
- :attr:`~Request.max_form_memory_size` Stop reading request data if any form part is
86+
larger than this number of bytes. While file parts can be moved to disk, regular
87+
form field data is stored in memory only.
88+
- :attr:`~Request.max_form_parts` Stop reading request data if more than this number
89+
of parts are sent in multipart form data. This is useful to stop a very large number
90+
of very small parts, especially file parts. The default is 1000.
91+
92+
Using Werkzeug to set these limits is only one layer of protection. WSGI servers
93+
and HTTPS servers should set their own limits on size and timeouts. The operating system
94+
or container manager should set limits on memory and processing time for server
95+
processes.
9396

9497

9598
How to extend Parsing?

src/werkzeug/formparser.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ class FormDataParser:
179179
:param cls: an optional dict class to use. If this is not specified
180180
or `None` the default :class:`MultiDict` is used.
181181
:param silent: If set to False parsing errors will not be caught.
182+
:param max_form_parts: The maximum number of parts to be parsed. If this is
183+
exceeded, a :exc:`~exceptions.RequestEntityTooLarge` exception is raised.
182184
"""
183185

184186
def __init__(
@@ -190,6 +192,8 @@ def __init__(
190192
max_content_length: t.Optional[int] = None,
191193
cls: t.Optional[t.Type[MultiDict]] = None,
192194
silent: bool = True,
195+
*,
196+
max_form_parts: t.Optional[int] = None,
193197
) -> None:
194198
if stream_factory is None:
195199
stream_factory = default_stream_factory
@@ -199,6 +203,7 @@ def __init__(
199203
self.errors = errors
200204
self.max_form_memory_size = max_form_memory_size
201205
self.max_content_length = max_content_length
206+
self.max_form_parts = max_form_parts
202207

203208
if cls is None:
204209
cls = MultiDict
@@ -281,6 +286,7 @@ def _parse_multipart(
281286
self.errors,
282287
max_form_memory_size=self.max_form_memory_size,
283288
cls=self.cls,
289+
max_form_parts=self.max_form_parts,
284290
)
285291
boundary = options.get("boundary", "").encode("ascii")
286292

@@ -346,10 +352,12 @@ def __init__(
346352
max_form_memory_size: t.Optional[int] = None,
347353
cls: t.Optional[t.Type[MultiDict]] = None,
348354
buffer_size: int = 64 * 1024,
355+
max_form_parts: t.Optional[int] = None,
349356
) -> None:
350357
self.charset = charset
351358
self.errors = errors
352359
self.max_form_memory_size = max_form_memory_size
360+
self.max_form_parts = max_form_parts
353361

354362
if stream_factory is None:
355363
stream_factory = default_stream_factory
@@ -409,7 +417,9 @@ def parse(
409417
[None],
410418
)
411419

412-
parser = MultipartDecoder(boundary, self.max_form_memory_size)
420+
parser = MultipartDecoder(
421+
boundary, self.max_form_memory_size, max_parts=self.max_form_parts
422+
)
413423

414424
fields = []
415425
files = []

src/werkzeug/sansio/multipart.py

+8
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,13 @@ def __init__(
8787
self,
8888
boundary: bytes,
8989
max_form_memory_size: Optional[int] = None,
90+
*,
91+
max_parts: Optional[int] = None,
9092
) -> None:
9193
self.buffer = bytearray()
9294
self.complete = False
9395
self.max_form_memory_size = max_form_memory_size
96+
self.max_parts = max_parts
9497
self.state = State.PREAMBLE
9598
self.boundary = boundary
9699

@@ -118,6 +121,7 @@ def __init__(
118121
re.MULTILINE,
119122
)
120123
self._search_position = 0
124+
self._parts_decoded = 0
121125

122126
def last_newline(self) -> int:
123127
try:
@@ -191,6 +195,10 @@ def next_event(self) -> Event:
191195
)
192196
self.state = State.DATA
193197
self._search_position = 0
198+
self._parts_decoded += 1
199+
200+
if self.max_parts is not None and self._parts_decoded > self.max_parts:
201+
raise RequestEntityTooLarge()
194202
else:
195203
# Update the search start position to be equal to the
196204
# current buffer length (already searched) minus a

src/werkzeug/wrappers/request.py

+8
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ class Request(_SansIORequest):
8383
#: .. versionadded:: 0.5
8484
max_form_memory_size: t.Optional[int] = None
8585

86+
#: The maximum number of multipart parts to parse, passed to
87+
#: :attr:`form_data_parser_class`. Parsing form data with more than this
88+
#: many parts will raise :exc:`~.RequestEntityTooLarge`.
89+
#:
90+
#: .. versionadded:: 2.2.3
91+
max_form_parts = 1000
92+
8693
#: The form data parser that should be used. Can be replaced to customize
8794
#: the form date parsing.
8895
form_data_parser_class: t.Type[FormDataParser] = FormDataParser
@@ -246,6 +253,7 @@ def make_form_data_parser(self) -> FormDataParser:
246253
self.max_form_memory_size,
247254
self.max_content_length,
248255
self.parameter_storage_class,
256+
max_form_parts=self.max_form_parts,
249257
)
250258

251259
def _load_form_data(self) -> None:

tests/test_formparser.py

+9
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ def test_limiting(self):
127127
req.max_form_memory_size = 400
128128
assert req.form["foo"] == "Hello World"
129129

130+
req = Request.from_values(
131+
input_stream=io.BytesIO(data),
132+
content_length=len(data),
133+
content_type="multipart/form-data; boundary=foo",
134+
method="POST",
135+
)
136+
req.max_form_parts = 1
137+
pytest.raises(RequestEntityTooLarge, lambda: req.form["foo"])
138+
130139
def test_missing_multipart_boundary(self):
131140
data = (
132141
b"--foo\r\nContent-Disposition: form-field; name=foo\r\n\r\n"

0 commit comments

Comments
 (0)