Werkzeug possible resource exhaustion when parsing file data in forms
Description
Werkzeug is a Web Server Gateway Interface web application library. Applications using werkzeug.formparser.MultiPartParser corresponding to a version of Werkzeug prior to 3.0.6 to parse multipart/form-data requests (e.g. all flask applications) are vulnerable to a relatively simple but effective resource exhaustion (denial of service) attack. A specifically crafted form submission request can cause the parser to allocate and block 3 to 8 times the upload size in main memory. There is no upper limit; a single upload at 1 Gbit/s can exhaust 32 GB of RAM in less than 60 seconds. Werkzeug version 3.0.6 fixes this issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
WerkzeugPyPI | < 3.0.6 | 3.0.6 |
QuartPyPI | < 0.20.0 | 0.20.0 |
Affected products
1Patches
3abb04a512496Support max_form_parts and max_form_memory_size
5 files changed · +97 −27
src/quart/app.py+5 −2 modified@@ -248,6 +248,8 @@ class Quart(App): "EXPLAIN_TEMPLATE_LOADING": False, "MAX_CONTENT_LENGTH": 16 * 1024 * 1024, # 16 MB Limit "MAX_COOKIE_SIZE": 4093, + "MAX_FORM_MEMORY_SIZE": 500_000, + "MAX_FORM_PARTS": 1_000, "PERMANENT_SESSION_LIFETIME": timedelta(days=31), # Replaces PREFERRED_URL_SCHEME to allow for WebSocket scheme "PREFER_SECURE_URLS": False, @@ -1130,8 +1132,9 @@ async def handle_websocket_exception( def log_exception( self, - exception_info: tuple[type, BaseException, TracebackType] - | tuple[None, None, None], + exception_info: ( + tuple[type, BaseException, TracebackType] | tuple[None, None, None] + ), ) -> None: """Log a exception to the :attr:`logger`.
src/quart/formparser.py+35 −16 modified@@ -43,15 +43,20 @@ class FormDataParser: def __init__( self, - stream_factory: StreamFactory = default_stream_factory, - max_form_memory_size: int | None = None, - max_content_length: int | None = None, + *, cls: type[MultiDict] | None = MultiDict, + max_content_length: int | None = None, + max_form_memory_size: int | None = None, + max_form_parts: int | None = None, silent: bool = True, + stream_factory: StreamFactory = default_stream_factory, ) -> None: - self.stream_factory = stream_factory self.cls = cls + self.max_content_length = max_content_length + self.max_form_memory_size = max_form_memory_size + self.max_form_parts = max_form_parts self.silent = silent + self.stream_factory = stream_factory def get_parse_func( self, mimetype: str, options: dict[str, str] @@ -87,9 +92,12 @@ async def _parse_multipart( options: dict[str, str], ) -> tuple[MultiDict, MultiDict]: parser = MultiPartParser( - self.stream_factory, cls=self.cls, file_storage_cls=self.file_storage_class, + max_content_length=self.max_content_length, + max_form_memory_size=self.max_form_memory_size, + max_form_parts=self.max_form_parts, + stream_factory=self.stream_factory, ) boundary = options.get("boundary", "").encode("ascii") @@ -105,10 +113,14 @@ async def _parse_urlencoded( content_length: int | None, options: dict[str, str], ) -> tuple[MultiDict, MultiDict]: - form = parse_qsl( - (await body).decode(), - keep_blank_values=True, - ) + try: + form = parse_qsl( + (await body).decode(), + keep_blank_values=True, + max_num_fields=self.max_form_parts, + ) + except ValueError: + raise RequestEntityTooLarge() from None return self.cls(form), self.cls() parse_functions: dict[str, ParserFunc] = { @@ -121,17 +133,22 @@ async def _parse_urlencoded( class MultiPartParser: def __init__( self, - stream_factory: StreamFactory = default_stream_factory, - max_form_memory_size: int | None = None, - cls: type[MultiDict] = MultiDict, + *, buffer_size: int = 64 * 1024, + cls: type[MultiDict] = MultiDict, file_storage_cls: type[FileStorage] = FileStorage, + max_content_length: int | None = None, + max_form_memory_size: int | None = None, + max_form_parts: int | None = None, + stream_factory: StreamFactory = default_stream_factory, ) -> None: - self.max_form_memory_size = max_form_memory_size - self.stream_factory = stream_factory - self.cls = cls self.buffer_size = buffer_size + self.cls = cls self.file_storage_cls = file_storage_cls + self.max_content_length = max_content_length + self.max_form_memory_size = max_form_memory_size + self.max_form_parts = max_form_parts + self.stream_factory = stream_factory def fail(self, message: str) -> NoReturn: raise ValueError(message) @@ -172,7 +189,9 @@ async def parse( container: IO[bytes] | list[bytes] _write: Callable[[bytes], Any] - parser = MultipartDecoder(boundary, self.max_form_memory_size) + parser = MultipartDecoder( + boundary, self.max_content_length, max_parts=self.max_form_parts + ) fields = [] files = []
src/quart/wrappers/base.py+0 −9 modified@@ -8,7 +8,6 @@ from werkzeug.sansio.request import Request as SansIORequest from .. import json -from ..globals import current_app if TYPE_CHECKING: from ..routing import QuartRule # noqa @@ -73,14 +72,6 @@ def __init__( self.http_version = http_version self.scope = scope - @property - def max_content_length(self) -> int | None: - """Read-only view of the ``MAX_CONTENT_LENGTH`` config key.""" - if current_app: - return current_app.config["MAX_CONTENT_LENGTH"] - else: - return None - @property def endpoint(self) -> str | None: """Returns the corresponding endpoint matched for this request.
src/quart/wrappers/request.py+47 −0 modified@@ -141,6 +141,9 @@ class Request(BaseRequestWebsocket): body_class = Body form_data_parser_class = FormDataParser lock_class = asyncio.Lock + _max_content_length: int | None = None + _max_form_memory_size: int | None = None + _max_form_parts: int | None = None def __init__( self, @@ -189,6 +192,48 @@ def __init__( self._parsing_lock = self.lock_class() self._send_push_promise = send_push_promise + @property + def max_content_length(self) -> int | None: + if self._max_content_length is not None: + return self._max_content_length + + if current_app: + return current_app.config["MAX_CONTENT_LENGTH"] + + return None + + @max_content_length.setter + def max_content_length(self, value: int | None) -> None: + self._max_content_length = value + + @property + def max_form_memory_size(self) -> int | None: + if self._max_form_memory_size is not None: + return self._max_form_memory_size + + if current_app: + return current_app.config["MAX_FORM_MEMORY_SIZE"] + + return None + + @max_form_memory_size.setter + def max_form_memory_size(self, value: int | None) -> None: + self._max_form_memory_size = value + + @property + def max_form_parts(self) -> int | None: + if self._max_form_parts is not None: + return self._max_form_parts + + if current_app: + return current_app.config["MAX_FORM_PARTS"] + + return None + + @max_form_parts.setter + def max_form_parts(self, value: int | None) -> None: + self._max_form_parts = value + @property async def stream(self) -> NoReturn: raise NotImplementedError("Use body instead") @@ -284,6 +329,8 @@ async def files(self) -> MultiDict: def make_form_data_parser(self) -> FormDataParser: return self.form_data_parser_class( max_content_length=self.max_content_length, + max_form_memory_size=self.max_form_memory_size, + max_form_parts=self.max_form_parts, cls=self.parameter_storage_class, )
tests/test_formparser.py+10 −0 modified@@ -3,6 +3,7 @@ import pytest from werkzeug.exceptions import RequestEntityTooLarge +from quart.formparser import FormDataParser from quart.formparser import MultiPartParser from quart.wrappers.request import Body @@ -19,3 +20,12 @@ async def test_multipart_max_form_memory_size() -> None: with pytest.raises(RequestEntityTooLarge): await parser.parse(body, b"bound", 0) + + +async def test_formparser_max_num_parts() -> None: + parser = FormDataParser(max_form_parts=1) + body = Body(None, None) + body.set_result(b"param1=data1¶m2=data2¶m3=data3") + + with pytest.raises(RequestEntityTooLarge): + await parser.parse(body, "application/x-url-encoded", None)
4 files changed · +28 −0
CHANGES.rst+3 −0 modified@@ -5,6 +5,9 @@ Version 3.0.6 Unreleased +- Fix how ``max_form_memory_size`` is applied when parsing large non-file + fields. :ghsa:`q34m-jh98-gwm2` + Version 3.0.5 -------------
src/werkzeug/formparser.py+11 −0 modified@@ -352,6 +352,7 @@ def parse( self, stream: t.IO[bytes], boundary: bytes, content_length: int | None ) -> tuple[MultiDict[str, str], MultiDict[str, FileStorage]]: current_part: Field | File + field_size: int | None = None container: t.IO[bytes] | list[bytes] _write: t.Callable[[bytes], t.Any] @@ -370,13 +371,23 @@ def parse( while not isinstance(event, (Epilogue, NeedData)): if isinstance(event, Field): current_part = event + field_size = 0 container = [] _write = container.append elif isinstance(event, File): current_part = event + field_size = None container = self.start_file_streaming(event, content_length) _write = container.write elif isinstance(event, Data): + if self.max_form_memory_size is not None and field_size is not None: + # Ensure that accumulated data events do not exceed limit. + # Also checked within single event in MultipartDecoder. + field_size += len(event.data) + + if field_size > self.max_form_memory_size: + raise RequestEntityTooLarge() + _write(event.data) if not event.more_data: if isinstance(current_part, Field):
src/werkzeug/sansio/multipart.py+2 −0 modified@@ -140,6 +140,8 @@ def receive_data(self, data: bytes | None) -> None: self.max_form_memory_size is not None and len(self.buffer) + len(data) > self.max_form_memory_size ): + # Ensure that data within single event does not exceed limit. + # Also checked across accumulated events in MultiPartParser. raise RequestEntityTooLarge() else: self.buffer.extend(data)
tests/test_formparser.py+12 −0 modified@@ -456,3 +456,15 @@ def test_file_rfc2231_filename_continuations(self): ) as request: assert request.files["rfc2231"].filename == "a b c d e f.txt" assert request.files["rfc2231"].read() == b"file contents" + + +def test_multipart_max_form_memory_size() -> None: + """max_form_memory_size is tracked across multiple data events.""" + data = b"--bound\r\nContent-Disposition: form-field; name=a\r\n\r\n" + data += b"a" * 15 + b"\r\n--bound--" + # The buffer size is less than the max size, so multiple data events will be + # returned. The field size is greater than the max. + parser = formparser.MultiPartParser(max_form_memory_size=10, buffer_size=5) + + with pytest.raises(RequestEntityTooLarge): + parser.parse(io.BytesIO(data), b"bound", None)
5e78c4169b8eapply max_form_memory_size another level up in the parser
4 files changed · +38 −1
CHANGES.rst+6 −0 modified@@ -1,3 +1,9 @@ +0.19.7 +------ + +* Security Fix how ``max_form_memory_size`` is applied when parsing large + non-file fields. https://github.com/advisories/GHSA-q34m-jh98-gwm2 + 0.19.6 2024-05-19 -----------------
pyproject.toml+1 −1 modified@@ -1,6 +1,6 @@ [tool.poetry] name = "Quart" -version = "0.19.6" +version = "0.19.7.dev" description = "A Python ASGI web microframework with the same API as Flask" authors = ["pgjones <philip.graham.jones@googlemail.com>"] classifiers = [
src/quart/formparser.py+10 −0 modified@@ -15,6 +15,7 @@ from urllib.parse import parse_qsl from werkzeug.datastructures import Headers, MultiDict +from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.formparser import default_stream_factory from werkzeug.http import parse_options_header from werkzeug.sansio.multipart import Data, Epilogue, Field, File, MultipartDecoder, NeedData @@ -173,19 +174,28 @@ async def parse( files = [] current_part: Field | File + field_size: int | None = None async for data in body: parser.receive_data(data) event = parser.next_event() while not isinstance(event, (Epilogue, NeedData)): if isinstance(event, Field): current_part = event + field_size = 0 container = [] _write = container.append elif isinstance(event, File): current_part = event + field_size = None container = self.start_file_streaming(event, content_length) _write = container.write elif isinstance(event, Data): + if field_size is not None: + field_size += len(event.data) + + if field_size > self.max_form_memory_size: + raise RequestEntityTooLarge() + _write(event.data) if not event.more_data: if isinstance(current_part, Field):
tests/test_formparser.py+21 −0 added@@ -0,0 +1,21 @@ +from __future__ import annotations + +import pytest +from werkzeug.exceptions import RequestEntityTooLarge + +from quart.formparser import MultiPartParser +from quart.wrappers.request import Body + + +async def test_multipart_max_form_memory_size() -> None: + """max_form_memory_size is tracked across multiple data events.""" + data = b"--bound\r\nContent-Disposition: form-field; name=a\r\n\r\n" + data += b"a" * 15 + b"\r\n--bound--" + body = Body(None, None) + body.set_result(data) + # The buffer size is less than the max size, so multiple data events will be + # returned. The field size is greater than the max. + parser = MultiPartParser(max_form_memory_size=10, buffer_size=5) + + with pytest.raises(RequestEntityTooLarge): + await parser.parse(body, b"bound", 0)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-q34m-jh98-gwm2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-49767ghsaADVISORY
- github.com/pallets/quart/commit/5e78c4169b8eb66b91ead3e62d44721b9e1644eeghsax_refsource_MISCWEB
- github.com/pallets/quart/commit/abb04a512496206de279225340ed022852fbf51fghsax_refsource_MISCWEB
- github.com/pallets/werkzeug/commit/50cfeebcb0727e18cc52ffbeb125f4a66551179bghsax_refsource_MISCWEB
- github.com/pallets/werkzeug/releases/tag/3.0.6ghsax_refsource_MISCWEB
- github.com/pallets/werkzeug/security/advisories/GHSA-q34m-jh98-gwm2ghsax_refsource_CONFIRMWEB
- security.netapp.com/advisory/ntap-20250103-0007ghsaWEB
News mentions
0No linked articles in our index yet.