Skip to content

Commit fe899d0

Browse files
pgjonesdavidism
authored andcommitted
limit the maximum number of multipart form parts
Add a limit to the number of multipart form data parts the parser will attempt to parse. If the limit is exceeded, it raises `RequestEntityTooLargeError`. A default of 1000 seems large enough to allow legitimate use cases while preventing the previous unlimited parsing. This differs from similar settings that are unset by default, as I think safe defaults are better practice. The limit can be adjusted per request by changing it on the request object before parsing. For example, it can be set based on what you expect a given endpoint to handle. ```python req.max_form_parts = 20 form = req.form ```
1 parent cf275f4 commit fe899d0

File tree

5 files changed

+43
-1
lines changed

5 files changed

+43
-1
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 100, after
25+
which a RequestEntityTooLarge exception is raised on parsing. The
26+
mitigates a DOS attack whereby a larger number file/form parts are
27+
sent resulting in a heavy parsing cost.
2428

2529

2630
Version 2.2.2

src/werkzeug/formparser.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ 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 accepted for the
183+
multipart data sent. If this is exceeded an
184+
:exc:`~exceptions.RequestEntityTooLarge` exception
185+
is raised.
182186
"""
183187

184188
def __init__(
@@ -190,6 +194,7 @@ def __init__(
190194
max_content_length: t.Optional[int] = None,
191195
cls: t.Optional[t.Type[MultiDict]] = None,
192196
silent: bool = True,
197+
max_form_parts: t.Optional[int] = None,
193198
) -> None:
194199
if stream_factory is None:
195200
stream_factory = default_stream_factory
@@ -205,6 +210,7 @@ def __init__(
205210

206211
self.cls = cls
207212
self.silent = silent
213+
self.max_form_parts = max_form_parts
208214

209215
def get_parse_func(
210216
self, mimetype: str, options: t.Dict[str, str]
@@ -281,6 +287,7 @@ def _parse_multipart(
281287
self.errors,
282288
max_form_memory_size=self.max_form_memory_size,
283289
cls=self.cls,
290+
max_form_parts=self.max_form_parts,
284291
)
285292
boundary = options.get("boundary", "").encode("ascii")
286293

@@ -346,10 +353,12 @@ def __init__(
346353
max_form_memory_size: t.Optional[int] = None,
347354
cls: t.Optional[t.Type[MultiDict]] = None,
348355
buffer_size: int = 64 * 1024,
356+
max_form_parts: t.Optional[int] = None,
349357
) -> None:
350358
self.charset = charset
351359
self.errors = errors
352360
self.max_form_memory_size = max_form_memory_size
361+
self.max_form_parts = max_form_parts
353362

354363
if stream_factory is None:
355364
stream_factory = default_stream_factory
@@ -409,7 +418,9 @@ def parse(
409418
[None],
410419
)
411420

412-
parser = MultipartDecoder(boundary, self.max_form_memory_size)
421+
parser = MultipartDecoder(
422+
boundary, self.max_form_memory_size, self.max_form_parts
423+
)
413424

414425
fields = []
415426
files = []

src/werkzeug/sansio/multipart.py

+7
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,12 @@ def __init__(
8787
self,
8888
boundary: bytes,
8989
max_form_memory_size: Optional[int] = None,
90+
max_parts: Optional[int] = None,
9091
) -> None:
9192
self.buffer = bytearray()
9293
self.complete = False
9394
self.max_form_memory_size = max_form_memory_size
95+
self.max_parts = max_parts
9496
self.state = State.PREAMBLE
9597
self.boundary = boundary
9698

@@ -118,6 +120,7 @@ def __init__(
118120
re.MULTILINE,
119121
)
120122
self._search_position = 0
123+
self._parts_decoded = 0
121124

122125
def last_newline(self) -> int:
123126
try:
@@ -191,6 +194,10 @@ def next_event(self) -> Event:
191194
)
192195
self.state = State.DATA
193196
self._search_position = 0
197+
self._parts_decoded += 1
198+
199+
if self.max_parts is not None and self._parts_decoded > self.max_parts:
200+
raise RequestEntityTooLarge()
194201
else:
195202
# Update the search start position to be equal to the
196203
# current buffer length (already searched) minus a

src/werkzeug/wrappers/request.py

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

86+
#: the maximum number of multipart parts. This is forwarded to teh
87+
#: form data parsing function (:func:`parse_form_data`). When the
88+
#: :attr:`form` or :attr:`files` attribute is accessed and the
89+
#: parsing fails because more parts than the specified value is
90+
#: transmitted a :exc:`~werkzeug.exceptions.RequestEntityTooLarge`
91+
#: exception is raised.
92+
#:
93+
#: .. versionadded:: 2.2.3
94+
max_form_parts = 1000
95+
8696
#: The form data parser that should be used. Can be replaced to customize
8797
#: the form date parsing.
8898
form_data_parser_class: t.Type[FormDataParser] = FormDataParser
@@ -246,6 +256,7 @@ def make_form_data_parser(self) -> FormDataParser:
246256
self.max_form_memory_size,
247257
self.max_content_length,
248258
self.parameter_storage_class,
259+
max_form_parts=self.max_form_parts,
249260
)
250261

251262
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)