From 5a6748108ef29de2e5037d68111e0c1ba9ee75b6 Mon Sep 17 00:00:00 2001 From: KayanoLiam Date: Mon, 14 Sep 2026 03:04:20 +0900 Subject: [PATCH] fix: respect per-request body size limits --- src/quart/wrappers/request.py | 45 ++++++------- tests/wrappers/test_request.py | 116 +++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 25 deletions(-) diff --git a/src/quart/wrappers/request.py b/src/quart/wrappers/request.py index 600db78..66d5c80 100644 --- a/src/quart/wrappers/request.py +++ b/src/quart/wrappers/request.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable from collections.abc import Callable from collections.abc import Generator +from types import EllipsisType from typing import Any from typing import Literal from typing import NoReturn @@ -55,26 +56,15 @@ def __init__( ) -> None: self._data: bytes | None = None self._complete: asyncio.Event = asyncio.Event() - self._max_content_length = max_content_length + self.max_content_length = max_content_length + self._expected_content_length = expected_content_length self._queue = asyncio.Queue[bytes]() - # Exceptions must be raised within application (not ASGI) - # calls, this is achieved by having the ASGI methods set this - # to an exception on error. - self._must_raise: Exception | None = None - if ( - expected_content_length is not None - and max_content_length is not None - and expected_content_length > max_content_length - ): - self._must_raise = RequestEntityTooLarge() - def __aiter__(self) -> Body: return self async def __anext__(self) -> bytes: - if self._must_raise is not None: - raise self._must_raise + self._check_content_length() if self._queue.empty() and self._complete.is_set(): raise StopAsyncIteration() @@ -82,31 +72,35 @@ async def __anext__(self) -> bytes: return await self.get() def __await__(self) -> Generator[Any, None, Any]: - # Must check the _must_raise before and after waiting on the - # completion event as it may change whilst waiting and the - # event may not be set if there is already an issue. - if self._must_raise is not None: - raise self._must_raise + self._check_content_length() if self._data is not None: return self._data yield from self._complete.wait().__await__() - if self._must_raise is not None: - raise self._must_raise + self._check_content_length() data = bytearray() while not self._queue.empty(): data.extend(self._queue.get_nowait()) if ( - self._max_content_length is not None - and len(data) > self._max_content_length + self.max_content_length is not None + and len(data) > self.max_content_length ): raise RequestEntityTooLarge() self._data = bytes(data) return self._data + def _check_content_length(self) -> None: + # The application may change the limit before consuming the body. + if ( + self._expected_content_length is not None + and self.max_content_length is not None + and self._expected_content_length > self.max_content_length + ): + raise RequestEntityTooLarge() + async def put(self, data: bytes) -> None: await self._queue.put(data) @@ -151,7 +145,7 @@ class Request(BaseRequestWebsocket): body_class = Body form_data_parser_class = FormDataParser lock_class = asyncio.Lock - _max_content_length: int | None = None + _max_content_length: int | None | EllipsisType = Ellipsis _max_form_memory_size: int | None = None _max_form_parts: int | None = None @@ -204,7 +198,7 @@ def __init__( @property def max_content_length(self) -> int | None: - if self._max_content_length is not None: + if self._max_content_length is not Ellipsis: return self._max_content_length if current_app: @@ -215,6 +209,7 @@ def max_content_length(self) -> int | None: @max_content_length.setter def max_content_length(self, value: int | None) -> None: self._max_content_length = value + self.body.max_content_length = value @property def max_form_memory_size(self) -> int | None: diff --git a/tests/wrappers/test_request.py b/tests/wrappers/test_request.py index f7a61af..7aac789 100644 --- a/tests/wrappers/test_request.py +++ b/tests/wrappers/test_request.py @@ -9,6 +9,8 @@ from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.exceptions import RequestTimeout +from quart import Quart +from quart import request from quart.testing import no_op_push from quart.wrappers.request import Body from quart.wrappers.request import Request @@ -93,6 +95,120 @@ async def test_request_exceeds_max_content_length(http_scope: HTTPScope) -> None await request.get_data() +@pytest.mark.parametrize("read_mode", ["body", "get_data", "stream", "form"]) +@pytest.mark.parametrize("with_content_length", [False, True]) +@pytest.mark.parametrize( + "limit, payload, expected_status", + [ + (None, b"a=1234", 200), + (0, b"a=1", 413), + (2, b"a=1", 413), + (6, b"a=1234", 200), + (8, b"a=1234", 200), + ], +) +async def test_request_max_content_length_override( + read_mode: str, + with_content_length: bool, + limit: int | None, + payload: bytes, + expected_status: int, +) -> None: + app = Quart(__name__) + app.config["MAX_CONTENT_LENGTH"] = 4 + + @app.put("/") + async def upload() -> dict: + request.max_content_length = limit + if read_mode == "stream": + data = b"".join([chunk async for chunk in request.body]) + elif read_mode == "form": + data = f"a={(await request.form)['a']}".encode() + elif read_mode == "get_data": + data = await request.get_data( + cache=True, as_text=False, parse_form_data=False + ) + else: + data = await request.body + return {"data": data.decode(), "limit": request.max_content_length} + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + if with_content_length: + headers["Content-Length"] = str(len(payload)) + + async with app.test_client().request( + "/", method="PUT", headers=headers + ) as connection: + for byte in payload: + await connection.send(bytes([byte])) + await connection.send_complete() + + response = await connection.as_response() + if read_mode == "stream" and not with_content_length: + expected_status = 200 + assert response.status_code == expected_status + if expected_status == 200: + assert await response.get_json() == {"data": payload.decode(), "limit": limit} + assert app.config["MAX_CONTENT_LENGTH"] == 4 + + +@pytest.mark.parametrize("limit", [None, 1024]) +@pytest.mark.parametrize("with_content_length", [False, True]) +async def test_request_max_content_length_multipart( + limit: int | None, with_content_length: bool +) -> None: + app = Quart(__name__) + app.config["MAX_CONTENT_LENGTH"] = 4 + + @app.put("/") + async def upload() -> dict: + request.max_content_length = limit + return dict(await request.form) + + payload = ( + b'--boundary\r\nContent-Disposition: form-data; name="a"\r\n' + b"\r\n1234\r\n--boundary--\r\n" + ) + headers = {"Content-Type": "multipart/form-data; boundary=boundary"} + if with_content_length: + headers["Content-Length"] = str(len(payload)) + + response = await app.test_client().put("/", data=payload, headers=headers) + assert response.status_code == 200 + assert await response.get_json() == {"a": "1234"} + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("with_content_length", [False, True]) +async def test_request_global_max_content_length( + stream: bool, with_content_length: bool +) -> None: + app = Quart(__name__) + app.config["MAX_CONTENT_LENGTH"] = 4 + + @app.put("/") + async def upload() -> bytes: + assert request.max_content_length == 4 + if stream: + return b"".join([chunk async for chunk in request.body]) + return await request.get_data(cache=True, as_text=False, parse_form_data=False) + + headers = {"Content-Length": "6"} if with_content_length else {} + async with app.test_client().request( + "/", method="PUT", headers=headers + ) as connection: + await connection.send(b"abc") + await connection.send(b"def") + await connection.send_complete() + + response = await connection.as_response() + if stream and not with_content_length: + assert response.status_code == 200 + assert await response.get_data() == b"abcdef" + else: + assert response.status_code == 413 + + async def test_request_get_data_timeout(http_scope: HTTPScope) -> None: request = Request( "POST",