Skip to content

Commit

Permalink
Merge pull request from GHSA-xg9f-g7g7-2323
Browse files Browse the repository at this point in the history
limit the maximum number of multipart form parts
  • Loading branch information
davidism committed Feb 14, 2023
2 parents cf275f4 + babc8d9 commit 517cac5
Show file tree
Hide file tree
Showing 6 changed files with 60 additions and 18 deletions.
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ Unreleased
the requested size in one ``read`` call. :issue:`2558`
- A cookie header that starts with ``=`` is treated as an empty key and discarded,
rather than stripping the leading ``==``.
- Specify a maximum number of multipart parts, default 1000, after which a
``RequestEntityTooLarge`` exception is raised on parsing. This mitigates a DoS
attack where a larger number of form/file parts would result in disproportionate
resource use.


Version 2.2.2
Expand Down
37 changes: 20 additions & 17 deletions docs/request_data.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,23 +73,26 @@ read the stream *or* call :meth:`~Request.get_data`.
Limiting Request Data
---------------------

To avoid being the victim of a DDOS attack you can set the maximum
accepted content length and request field sizes. The :class:`Request`
class has two attributes for that: :attr:`~Request.max_content_length`
and :attr:`~Request.max_form_memory_size`.

The first one can be used to limit the total content length. For example
by setting it to ``1024 * 1024 * 16`` the request won't accept more than
16MB of transmitted data.

Because certain data can't be moved to the hard disk (regular post data)
whereas temporary files can, there is a second limit you can set. The
:attr:`~Request.max_form_memory_size` limits the size of `POST`
transmitted form data. By setting it to ``1024 * 1024 * 2`` you can make
sure that all in memory-stored fields are not more than 2MB in size.

This however does *not* affect in-memory stored files if the
`stream_factory` used returns a in-memory file.
The :class:`Request` class provides a few attributes to control how much data is
processed from the request body. This can help mitigate DoS attacks that craft the
request in such a way that the server uses too many resources to handle it. Each of
these limits will raise a :exc:`~werkzeug.exceptions.RequestEntityTooLarge` if they are
exceeded.

- :attr:`~Request.max_content_length` Stop reading request data after this number
of bytes. It's better to configure this in the WSGI server or HTTP server, rather
than the WSGI application.
- :attr:`~Request.max_form_memory_size` Stop reading request data if any form part is
larger than this number of bytes. While file parts can be moved to disk, regular
form field data is stored in memory only.
- :attr:`~Request.max_form_parts` Stop reading request data if more than this number
of parts are sent in multipart form data. This is useful to stop a very large number
of very small parts, especially file parts. The default is 1000.

Using Werkzeug to set these limits is only one layer of protection. WSGI servers
and HTTPS servers should set their own limits on size and timeouts. The operating system
or container manager should set limits on memory and processing time for server
processes.


How to extend Parsing?
Expand Down
12 changes: 11 additions & 1 deletion src/werkzeug/formparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ class FormDataParser:
:param cls: an optional dict class to use. If this is not specified
or `None` the default :class:`MultiDict` is used.
:param silent: If set to False parsing errors will not be caught.
:param max_form_parts: The maximum number of parts to be parsed. If this is
exceeded, a :exc:`~exceptions.RequestEntityTooLarge` exception is raised.
"""

def __init__(
Expand All @@ -190,6 +192,8 @@ def __init__(
max_content_length: t.Optional[int] = None,
cls: t.Optional[t.Type[MultiDict]] = None,
silent: bool = True,
*,
max_form_parts: t.Optional[int] = None,
) -> None:
if stream_factory is None:
stream_factory = default_stream_factory
Expand All @@ -199,6 +203,7 @@ def __init__(
self.errors = errors
self.max_form_memory_size = max_form_memory_size
self.max_content_length = max_content_length
self.max_form_parts = max_form_parts

if cls is None:
cls = MultiDict
Expand Down Expand Up @@ -281,6 +286,7 @@ def _parse_multipart(
self.errors,
max_form_memory_size=self.max_form_memory_size,
cls=self.cls,
max_form_parts=self.max_form_parts,
)
boundary = options.get("boundary", "").encode("ascii")

Expand Down Expand Up @@ -346,10 +352,12 @@ def __init__(
max_form_memory_size: t.Optional[int] = None,
cls: t.Optional[t.Type[MultiDict]] = None,
buffer_size: int = 64 * 1024,
max_form_parts: t.Optional[int] = None,
) -> None:
self.charset = charset
self.errors = errors
self.max_form_memory_size = max_form_memory_size
self.max_form_parts = max_form_parts

if stream_factory is None:
stream_factory = default_stream_factory
Expand Down Expand Up @@ -409,7 +417,9 @@ def parse(
[None],
)

parser = MultipartDecoder(boundary, self.max_form_memory_size)
parser = MultipartDecoder(
boundary, self.max_form_memory_size, max_parts=self.max_form_parts
)

fields = []
files = []
Expand Down
8 changes: 8 additions & 0 deletions src/werkzeug/sansio/multipart.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,13 @@ def __init__(
self,
boundary: bytes,
max_form_memory_size: Optional[int] = None,
*,
max_parts: Optional[int] = None,
) -> None:
self.buffer = bytearray()
self.complete = False
self.max_form_memory_size = max_form_memory_size
self.max_parts = max_parts
self.state = State.PREAMBLE
self.boundary = boundary

Expand Down Expand Up @@ -118,6 +121,7 @@ def __init__(
re.MULTILINE,
)
self._search_position = 0
self._parts_decoded = 0

def last_newline(self) -> int:
try:
Expand Down Expand Up @@ -191,6 +195,10 @@ def next_event(self) -> Event:
)
self.state = State.DATA
self._search_position = 0
self._parts_decoded += 1

if self.max_parts is not None and self._parts_decoded > self.max_parts:
raise RequestEntityTooLarge()
else:
# Update the search start position to be equal to the
# current buffer length (already searched) minus a
Expand Down
8 changes: 8 additions & 0 deletions src/werkzeug/wrappers/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ class Request(_SansIORequest):
#: .. versionadded:: 0.5
max_form_memory_size: t.Optional[int] = None

#: The maximum number of multipart parts to parse, passed to
#: :attr:`form_data_parser_class`. Parsing form data with more than this
#: many parts will raise :exc:`~.RequestEntityTooLarge`.
#:
#: .. versionadded:: 2.2.3
max_form_parts = 1000

#: The form data parser that should be used. Can be replaced to customize
#: the form date parsing.
form_data_parser_class: t.Type[FormDataParser] = FormDataParser
Expand Down Expand Up @@ -246,6 +253,7 @@ def make_form_data_parser(self) -> FormDataParser:
self.max_form_memory_size,
self.max_content_length,
self.parameter_storage_class,
max_form_parts=self.max_form_parts,
)

def _load_form_data(self) -> None:
Expand Down
9 changes: 9 additions & 0 deletions tests/test_formparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,15 @@ def test_limiting(self):
req.max_form_memory_size = 400
assert req.form["foo"] == "Hello World"

req = Request.from_values(
input_stream=io.BytesIO(data),
content_length=len(data),
content_type="multipart/form-data; boundary=foo",
method="POST",
)
req.max_form_parts = 1
pytest.raises(RequestEntityTooLarge, lambda: req.form["foo"])

def test_missing_multipart_boundary(self):
data = (
b"--foo\r\nContent-Disposition: form-field; name=foo\r\n\r\n"
Expand Down

0 comments on commit 517cac5

Please sign in to comment.