| From 5a56cdcbaec2153cd67596c6c2c8056e1ea5ed56 Mon Sep 17 00:00:00 2001 |
| From: David Lord <davidism@gmail.com> |
| Date: Tue, 2 May 2023 11:31:10 +0000 |
| Subject: [PATCH] Merge pull request from GHSA-xg9f-g7g7-2323 |
| |
| limit the maximum number of multipart form parts |
| |
| CVE: CVE-2023-25577 |
| |
| Upstream-Status: Backport [https://github.com/pallets/werkzeug/commit/517cac5a804e8c4dc4ed038bb20dacd038e7a9f1] |
| |
| Signed-off-by: Narpat Mali <narpat.mali@windriver.com> |
| --- |
| CHANGES.rst | 5 +++++ |
| docs/request_data.rst | 37 +++++++++++++++++--------------- |
| src/werkzeug/formparser.py | 12 ++++++++++- |
| src/werkzeug/sansio/multipart.py | 8 +++++++ |
| src/werkzeug/wrappers/request.py | 8 +++++++ |
| tests/test_formparser.py | 9 ++++++++ |
| 6 files changed, 61 insertions(+), 18 deletions(-) |
| |
| diff --git a/CHANGES.rst b/CHANGES.rst |
| index a351d7c..6e809ba 100644 |
| --- a/CHANGES.rst |
| +++ b/CHANGES.rst |
| @@ -1,5 +1,10 @@ |
| .. currentmodule:: werkzeug |
| |
| +- 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.1.1 |
| ------------- |
| |
| diff --git a/docs/request_data.rst b/docs/request_data.rst |
| index 83c6278..e55841e 100644 |
| --- a/docs/request_data.rst |
| +++ b/docs/request_data.rst |
| @@ -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? |
| diff --git a/src/werkzeug/formparser.py b/src/werkzeug/formparser.py |
| index 10d58ca..bebb2fc 100644 |
| --- a/src/werkzeug/formparser.py |
| +++ b/src/werkzeug/formparser.py |
| @@ -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__( |
| @@ -190,6 +192,8 @@ class FormDataParser: |
| 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 |
| @@ -199,6 +203,7 @@ class FormDataParser: |
| 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 |
| @@ -281,6 +286,7 @@ class FormDataParser: |
| 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") |
| |
| @@ -346,10 +352,12 @@ class MultiPartParser: |
| 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 |
| @@ -409,7 +417,9 @@ class MultiPartParser: |
| [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 = [] |
| diff --git a/src/werkzeug/sansio/multipart.py b/src/werkzeug/sansio/multipart.py |
| index 2d54422..e7d742b 100644 |
| --- a/src/werkzeug/sansio/multipart.py |
| +++ b/src/werkzeug/sansio/multipart.py |
| @@ -83,10 +83,13 @@ class MultipartDecoder: |
| 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 |
| |
| @@ -113,6 +116,7 @@ class MultipartDecoder: |
| % (LINE_BREAK, re.escape(boundary), LINE_BREAK, LINE_BREAK), |
| re.MULTILINE, |
| ) |
| + self._parts_decoded = 0 |
| |
| def last_newline(self) -> int: |
| try: |
| @@ -177,6 +181,10 @@ class MultipartDecoder: |
| name=name, |
| ) |
| self.state = State.DATA |
| + self._parts_decoded += 1 |
| + |
| + if self.max_parts is not None and self._parts_decoded > self.max_parts: |
| + raise RequestEntityTooLarge() |
| |
| elif self.state == State.DATA: |
| if self.buffer.find(b"--" + self.boundary) == -1: |
| diff --git a/src/werkzeug/wrappers/request.py b/src/werkzeug/wrappers/request.py |
| index 57b739c..a6d5429 100644 |
| --- a/src/werkzeug/wrappers/request.py |
| +++ b/src/werkzeug/wrappers/request.py |
| @@ -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 |
| @@ -246,6 +253,7 @@ class Request(_SansIORequest): |
| 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: |
| diff --git a/tests/test_formparser.py b/tests/test_formparser.py |
| index 5fc803e..834324f 100644 |
| --- a/tests/test_formparser.py |
| +++ b/tests/test_formparser.py |
| @@ -127,6 +127,15 @@ class TestFormParser: |
| 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" |
| -- |
| 2.40.0 |