Skip to content

Commit

Permalink
Merge commit from fork
Browse files Browse the repository at this point in the history
Co-authored-by: Jacob Coffee <[email protected]>
Co-authored-by: guacs <[email protected]>
  • Loading branch information
3 people committed Nov 20, 2024
1 parent 7e56814 commit 53c1473
Show file tree
Hide file tree
Showing 13 changed files with 387 additions and 67 deletions.
49 changes: 49 additions & 0 deletions docs/usage/requests.rst
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,52 @@ The example below illustrates how to implement custom request class for the whol
class on multiple layers, the layer closest to the route handler will take precedence.

You can read more about this in the :ref:`usage/applications:layered architecture` section


Limits
-------

Body size
^^^^^^^^^^

A limit for the allowed request body size can be set on all layers via the
``request_max_body_size`` parameter and defaults to 10MB. If a request body exceeds this
limit, a ``413 - Request Entity Too Large``
response will be returned. This limit applies to all methods of consuming the request
body, including requesting it via the ``body`` parameter in a route handler and
consuming it through a manually constructed :class:`~litestar.connection.Request`
instance, e.g. in a middleware.

To disable this limit for a specific handler / router / controller, it can be set to
:obj:`None`.

.. danger::
Setting ``request_max_body_size=None`` is strongly discouraged as it exposes the
application to a denial of service (DoS) attack by sending arbitrarily large
request bodies to the affected endpoint. Because Litestar has to read the whole body
to perform certain actions, such as parsing JSON, it will fill up all the available
memory / swap until the application / server crashes, should no outside limits be
imposed.

This is generally only recommended in environments where the application is running
behind a reverse proxy such as NGINX, where a size limit is already set.


.. danger::
Since ``request_max_body_size`` is handled on a per-request basis, it won't affect
middlewares or ASGI handlers when they try to access the request body via the raw
ASGI events. To avoid this, middlewares and ASGI handlers should construct a
:class:`~litestar.connection.Request` instance and use the regular
:meth:`~litestar.connection.Request.stream` /
:meth:`~litestar.connection.Request.body` or content-appropriate method to consume
the request body in a safe manner.


.. tip::
For requests that define a ``Content-Length`` header, Litestar will not attempt to
read the request body should the header value exceed the ``request_max_body_size``.

If the header value is within the allowed bounds, Litestar will verify during the
streaming of the request body that it does not exceed the size specified in the
header. Should the request exceed this size, it will abort the request with a
``400 - Bad Request``.
5 changes: 5 additions & 0 deletions litestar/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ def __init__(
path: str | None = None,
plugins: Sequence[PluginProtocol] | None = None,
request_class: type[Request] | None = None,
request_max_body_size: int | None = 10_000_000,
response_cache_config: ResponseCacheConfig | None = None,
response_class: type[Response] | None = None,
response_cookies: ResponseCookies | None = None,
Expand Down Expand Up @@ -286,6 +287,8 @@ def __init__(
pdb_on_exception: Drop into the PDB when an exception occurs.
plugins: Sequence of plugins.
request_class: An optional subclass of :class:`Request <.connection.Request>` to use for http connections.
request_max_body_size: Maximum allowed size of the request body in bytes. If this size is exceeded, a
'413 - Request Entity Too Large' error response is returned.
response_class: A custom subclass of :class:`Response <.response.Response>` to be used as the app's default
response.
response_cookies: A sequence of :class:`Cookie <.datastructures.Cookie>`.
Expand Down Expand Up @@ -361,6 +364,7 @@ def __init__(
pdb_on_exception=pdb_on_exception,
plugins=self._get_default_plugins(list(plugins or [])),
request_class=request_class,
request_max_body_size=request_max_body_size,
response_cache_config=response_cache_config or ResponseCacheConfig(),
response_class=response_class,
response_cookies=response_cookies or [],
Expand Down Expand Up @@ -464,6 +468,7 @@ def __init__(
parameters=config.parameters,
path=config.path,
request_class=self.request_class,
request_max_body_size=request_max_body_size,
response_class=config.response_class,
response_cookies=config.response_cookies,
response_headers=config.response_headers,
Expand Down
3 changes: 3 additions & 0 deletions litestar/config/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ class AppConfig:
"""List of :class:`SerializationPluginProtocol <.plugins.SerializationPluginProtocol>`."""
request_class: type[Request] | None = field(default=None)
"""An optional subclass of :class:`Request <.connection.Request>` to use for http connections."""
request_max_body_size: int | None | EmptyType = Empty
"""Maximum allowed size of the request body in bytes. If this size is exceeded, a '413 - Request Entity Too Large'
error response is returned."""
response_class: type[Response] | None = field(default=None)
"""A custom subclass of :class:`Response <.response.Response>` to be used as the app's default response."""
response_cookies: ResponseCookies = field(default_factory=list)
Expand Down
66 changes: 61 additions & 5 deletions litestar/connection/request.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

import math
import warnings
from typing import TYPE_CHECKING, Any, AsyncGenerator, Generic
from typing import TYPE_CHECKING, Any, AsyncGenerator, Generic, cast

from litestar._multipart import parse_content_header, parse_multipart_form
from litestar._parsers import parse_url_encoded_form_data
Expand All @@ -17,12 +18,14 @@
from litestar.datastructures.multi_dicts import FormMultiDict
from litestar.enums import ASGIExtension, RequestEncodingType
from litestar.exceptions import (
ClientException,
InternalServerException,
LitestarException,
LitestarWarning,
)
from litestar.exceptions.http_exceptions import RequestEntityTooLarge
from litestar.serialization import decode_json, decode_msgpack
from litestar.types import Empty
from litestar.types import Empty, HTTPReceiveMessage

__all__ = ("Request",)

Expand Down Expand Up @@ -52,6 +55,7 @@ class Request(Generic[UserT, AuthT, StateT], ASGIConnection["HTTPRouteHandler",
"_msgpack",
"_content_type",
"_accept",
"_content_length",
"is_connected",
"supports_push_promise",
)
Expand Down Expand Up @@ -79,6 +83,7 @@ def __init__(self, scope: Scope, receive: Receive = empty_receive, send: Send =
self._msgpack: Any = Empty
self._content_type: tuple[str, dict[str, str]] | EmptyType = Empty
self._accept: Accept | EmptyType = Empty
self._content_length: int | None | EmptyType = Empty
self.supports_push_promise = ASGIExtension.SERVER_PUSH in self._server_extensions

@property
Expand Down Expand Up @@ -152,6 +157,21 @@ async def msgpack(self) -> Any:
)
return self._msgpack

@property
def content_length(self) -> int | None:
cached_content_length = self._content_length
if cached_content_length is not Empty:
return cached_content_length

content_length_header = self.headers.get("content-length")
try:
content_length = self._content_length = (
int(content_length_header) if content_length_header is not None else None
)
except ValueError:
raise ClientException(f"Invalid content-length: {content_length_header!r}") from None
return content_length

async def stream(self) -> AsyncGenerator[bytes, None]:
"""Return an async generator that streams chunks of bytes.
Expand All @@ -164,10 +184,46 @@ async def stream(self) -> AsyncGenerator[bytes, None]:
if self._body is Empty:
if not self.is_connected:
raise InternalServerException("stream consumed")
while event := await self.receive():

announced_content_length = self.content_length
# setting this to 'math.inf' as a micro-optimisation; Comparing against a
# float is slightly faster than checking if a value is 'None' and then
# comparing it to an int. since we expect a limit to be set most of the
# time, this is a bit more efficient
max_content_length = self.route_handler.resolve_request_max_body_size() or math.inf

# if the 'content-length' header is set, and exceeds the limit, we can bail
# out early before reading anything
if announced_content_length is not None and announced_content_length > max_content_length:
raise RequestEntityTooLarge

total_bytes_streamed: int = 0
while event := cast("HTTPReceiveMessage", await self.receive()):
if event["type"] == "http.request":
if event["body"]:
yield event["body"]
body = event["body"]
if body:
total_bytes_streamed += len(body)

# if a 'content-length' header was set, check if we have
# received more bytes than specified. in most cases this should
# be caught before it hits the application layer and an ASGI
# server (e.g. uvicorn) will not allow this, but since it's not
# forbidden according to the HTTP or ASGI spec, we err on the
# side of caution and still perform this check.
#
# uvicorn documented behaviour for this case:
# https://github.com/encode/uvicorn/blob/fe3910083e3990695bc19c2ef671dd447262ae18/docs/server-behavior.md?plain=1#L11
if announced_content_length:
if total_bytes_streamed > announced_content_length:
raise ClientException("Malformed request")

# we don't have a 'content-length' header, likely a chunked
# transfer. we don't really care and simply check if we have
# received more bytes than allowed
elif total_bytes_streamed > max_content_length:
raise RequestEntityTooLarge

yield body

if not event.get("more_body", False):
break
Expand Down
10 changes: 10 additions & 0 deletions litestar/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class Controller:
"parameters",
"path",
"request_class",
"request_max_body_size",
"response_class",
"response_cookies",
"response_headers",
Expand Down Expand Up @@ -136,6 +137,11 @@ class Controller:
"""A custom subclass of :class:`Request <.connection.Request>` to be used as the default request for all route
handlers under the controller.
"""
request_max_body_size: int | None | EmptyType
"""
Maximum allowed size of the request body in bytes. If this size is exceeded, a '413 - Request Entity Too Large'
error response is returned."""

response_class: type[Response] | None
"""A custom subclass of :class:`Response <.response.Response>` to be used as the default response for all route
handlers under the controller.
Expand Down Expand Up @@ -191,6 +197,9 @@ def __init__(self, owner: Router) -> None:
if not hasattr(self, "include_in_schema"):
self.include_in_schema = Empty

if not hasattr(self, "request_max_body_size"):
self.request_max_body_size = Empty

self.signature_namespace = add_types_to_signature_namespace(
getattr(self, "signature_types", []), getattr(self, "signature_namespace", {})
)
Expand Down Expand Up @@ -235,6 +244,7 @@ def as_router(self) -> Router:
type_encoders=self.type_encoders,
type_decoders=self.type_decoders,
websocket_class=self.websocket_class,
request_max_body_size=self.request_max_body_size,
)
router.owner = self.owner
return router
Expand Down
6 changes: 6 additions & 0 deletions litestar/exceptions/http_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
HTTP_405_METHOD_NOT_ALLOWED,
HTTP_413_REQUEST_ENTITY_TOO_LARGE,
HTTP_429_TOO_MANY_REQUESTS,
HTTP_500_INTERNAL_SERVER_ERROR,
HTTP_503_SERVICE_UNAVAILABLE,
Expand Down Expand Up @@ -119,6 +120,11 @@ class MethodNotAllowedException(ClientException):
status_code = HTTP_405_METHOD_NOT_ALLOWED


class RequestEntityTooLarge(ClientException):
status_code = HTTP_413_REQUEST_ENTITY_TOO_LARGE
detail = "Request Entity Too Large"


class TooManyRequestsException(ClientException):
"""Request limits have been exceeded."""

Expand Down
26 changes: 26 additions & 0 deletions litestar/handlers/http_handlers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class HTTPRouteHandler(BaseRouteHandler):
"_resolved_request_class",
"_resolved_tags",
"_resolved_security",
"_resolved_request_max_body_size",
"after_request",
"after_response",
"background",
Expand Down Expand Up @@ -113,6 +114,7 @@ class HTTPRouteHandler(BaseRouteHandler):
"sync_to_thread",
"tags",
"template_name",
"request_max_body_size",
)

has_sync_callable: bool
Expand All @@ -139,6 +141,7 @@ def __init__(
name: str | None = None,
opt: Mapping[str, Any] | None = None,
request_class: type[Request] | None = None,
request_max_body_size: int | None | EmptyType = Empty,
response_class: type[Response] | None = None,
response_cookies: ResponseCookies | None = None,
response_headers: ResponseHeaders | None = None,
Expand Down Expand Up @@ -204,6 +207,8 @@ def __init__(
:class:`ASGI Scope <.types.Scope>`.
request_class: A custom subclass of :class:`Request <.connection.Request>` to be used as route handler's
default request.
request_max_body_size: Maximum allowed size of the request body in bytes. If this size is exceeded,
a '413 - Request Entity Too Large' error response is returned.
response_class: A custom subclass of :class:`Response <.response.Response>` to be used as route handler's
default response.
response_cookies: A sequence of :class:`Cookie <.datastructures.Cookie>` instances.
Expand Down Expand Up @@ -272,6 +277,7 @@ def __init__(
self.response_class = response_class
self.response_cookies: Sequence[Cookie] | None = narrow_response_cookies(response_cookies)
self.response_headers: Sequence[ResponseHeader] | None = narrow_response_headers(response_headers)
self.request_max_body_size = request_max_body_size

self.sync_to_thread = sync_to_thread
# OpenAPI related attributes
Expand All @@ -297,6 +303,7 @@ def __init__(
self._resolved_request_class: type[Request] | EmptyType = Empty
self._resolved_security: list[SecurityRequirement] | EmptyType = Empty
self._resolved_tags: list[str] | EmptyType = Empty
self._resolved_request_max_body_size: int | EmptyType | None = Empty

def __call__(self, fn: AnyCallable) -> HTTPRouteHandler:
"""Replace a function with itself."""
Expand Down Expand Up @@ -473,6 +480,25 @@ def resolve_tags(self) -> list[str]:

return self._resolved_tags

def resolve_request_max_body_size(self) -> int | None:
if (resolved_limits := self._resolved_request_max_body_size) is not Empty:
return resolved_limits

max_body_size = self._resolved_request_max_body_size = next( # pyright: ignore
(
max_body_size
for layer in reversed(self.ownership_layers)
if (max_body_size := layer.request_max_body_size) is not Empty
),
Empty,
)
if max_body_size is Empty:
raise ImproperlyConfiguredException(
"'request_max_body_size' set to 'Empty' on all layers. To omit a limit, "
"set 'request_max_body_size=None'"
)
return max_body_size

def get_response_handler(self, is_response_type_data: bool = False) -> Callable[[Any], Awaitable[ASGIApp]]:
"""Resolve the response_handler function for the route handler.
Expand Down
Loading

0 comments on commit 53c1473

Please sign in to comment.