Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 20 additions & 25 deletions src/quart/wrappers/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,58 +56,51 @@ 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()

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)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
116 changes: 116 additions & 0 deletions tests/wrappers/test_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
Loading