diff --git a/docs/middleware.md b/docs/middleware.md index 759c86d70..9f720098c 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -94,7 +94,7 @@ request through as normal, but will include appropriate CORS headers on the resp ## SessionMiddleware -Adds signed cookie-based HTTP sessions. Session information is readable but not modifiable. +Adds signed cookie-based HTTP sessions. Session cookie information is user readable but not user modifiable, the data stored is ***not*** encrypted. Access or modify the session data using the `request.session` dictionary interface. @@ -103,6 +103,7 @@ The following arguments are supported: * `secret_key` - Should be a random string. * `session_cookie` - Defaults to "session". * `max_age` - Session expiry time in seconds. Defaults to 2 weeks. If set to `None` then the cookie will last as long as the browser session. +* `refresh_window` - Refresh window in seconds before max_age. If set the cookie will automatically refresh with in that timeframe when used to a new max_age. Defaults to `None`. * `same_site` - SameSite flag prevents the browser from sending session cookie along with cross-site requests. Defaults to `'lax'`. * `https_only` - Indicate that Secure flag should be set (can be used with HTTPS only). Defaults to `False`. * `domain` - Domain of the cookie used to share cookie between subdomains or cross-domains. The browser defaults the domain to the same host that set the cookie, excluding subdomains [refrence](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#domain_attribute). diff --git a/docs/staticfiles.md b/docs/staticfiles.md index 3e01f0775..9f720098c 100644 --- a/docs/staticfiles.md +++ b/docs/staticfiles.md @@ -1,66 +1,846 @@ -Starlette also includes a `StaticFiles` class for serving files in a given directory: +Starlette includes several middleware classes for adding behavior that is applied across +your entire application. These are all implemented as standard ASGI +middleware classes, and can be applied either to Starlette or to any other ASGI application. -### StaticFiles +## Using middleware -Signature: `StaticFiles(directory=None, packages=None, html=False, check_dir=True, follow_symlink=False)` +The Starlette application class allows you to include the ASGI middleware +in a way that ensures that it remains wrapped by the exception handler. -* `directory` - A string or [os.PathLike][pathlike] denoting a directory path. -* `packages` - A list of strings or list of tuples of strings of python packages. -* `html` - Run in HTML mode. Automatically loads `index.html` for directories if such file exist. -* `check_dir` - Ensure that the directory exists upon instantiation. Defaults to `True`. -* `follow_symlink` - A boolean indicating if symbolic links for files and directories should be followed. Defaults to `False`. +```python +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware +from starlette.middleware.trustedhost import TrustedHostMiddleware + +routes = ... + +# Ensure that all requests include an 'example.com' or +# '*.example.com' host header, and strictly enforce https-only access. +middleware = [ + Middleware( + TrustedHostMiddleware, + allowed_hosts=['example.com', '*.example.com'], + ), + Middleware(HTTPSRedirectMiddleware) +] + +app = Starlette(routes=routes, middleware=middleware) +``` + +Every Starlette application automatically includes two pieces of middleware by default: + +* `ServerErrorMiddleware` - Ensures that application exceptions may return a custom 500 page, or display an application traceback in DEBUG mode. This is *always* the outermost middleware layer. +* `ExceptionMiddleware` - Adds exception handlers, so that particular types of expected exception cases can be associated with handler functions. For example raising `HTTPException(status_code=404)` within an endpoint will end up rendering a custom 404 page. + +Middleware is evaluated from top-to-bottom, so the flow of execution in our example +application would look like this: + +* Middleware + * `ServerErrorMiddleware` + * `TrustedHostMiddleware` + * `HTTPSRedirectMiddleware` + * `ExceptionMiddleware` +* Routing +* Endpoint + +The following middleware implementations are available in the Starlette package: + +## CORSMiddleware + +Adds appropriate [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) to outgoing responses in order to allow cross-origin requests from browsers. + +The default parameters used by the CORSMiddleware implementation are restrictive by default, +so you'll need to explicitly enable particular origins, methods, or headers, in order +for browsers to be permitted to use them in a Cross-Domain context. + +```python +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware + +routes = ... + +middleware = [ + Middleware(CORSMiddleware, allow_origins=['*']) +] + +app = Starlette(routes=routes, middleware=middleware) +``` + +The following arguments are supported: + +* `allow_origins` - A list of origins that should be permitted to make cross-origin requests. eg. `['https://example.org', 'https://www.example.org']`. You can use `['*']` to allow any origin. +* `allow_origin_regex` - A regex string to match against origins that should be permitted to make cross-origin requests. eg. `'https://.*\.example\.org'`. +* `allow_methods` - A list of HTTP methods that should be allowed for cross-origin requests. Defaults to `['GET']`. You can use `['*']` to allow all standard methods. +* `allow_headers` - A list of HTTP request headers that should be supported for cross-origin requests. Defaults to `[]`. You can use `['*']` to allow all headers. The `Accept`, `Accept-Language`, `Content-Language` and `Content-Type` headers are always allowed for CORS requests. +* `allow_credentials` - Indicate that cookies should be supported for cross-origin requests. Defaults to `False`. Also, `allow_origins`, `allow_methods` and `allow_headers` cannot be set to `['*']` for credentials to be allowed, all of them must be explicitly specified. +* `expose_headers` - Indicate any response headers that should be made accessible to the browser. Defaults to `[]`. +* `max_age` - Sets a maximum time in seconds for browsers to cache CORS responses. Defaults to `600`. + +The middleware responds to two particular types of HTTP request... + +#### CORS preflight requests + +These are any `OPTIONS` request with `Origin` and `Access-Control-Request-Method` headers. +In this case the middleware will intercept the incoming request and respond with +appropriate CORS headers, and either a 200 or 400 response for informational purposes. + +#### Simple requests + +Any request with an `Origin` header. In this case the middleware will pass the +request through as normal, but will include appropriate CORS headers on the response. + +## SessionMiddleware + +Adds signed cookie-based HTTP sessions. Session cookie information is user readable but not user modifiable, the data stored is ***not*** encrypted. + +Access or modify the session data using the `request.session` dictionary interface. + +The following arguments are supported: + +* `secret_key` - Should be a random string. +* `session_cookie` - Defaults to "session". +* `max_age` - Session expiry time in seconds. Defaults to 2 weeks. If set to `None` then the cookie will last as long as the browser session. +* `refresh_window` - Refresh window in seconds before max_age. If set the cookie will automatically refresh with in that timeframe when used to a new max_age. Defaults to `None`. +* `same_site` - SameSite flag prevents the browser from sending session cookie along with cross-site requests. Defaults to `'lax'`. +* `https_only` - Indicate that Secure flag should be set (can be used with HTTPS only). Defaults to `False`. +* `domain` - Domain of the cookie used to share cookie between subdomains or cross-domains. The browser defaults the domain to the same host that set the cookie, excluding subdomains [refrence](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#domain_attribute). + + +```python +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.sessions import SessionMiddleware + +routes = ... + +middleware = [ + Middleware(SessionMiddleware, secret_key=..., https_only=True) +] + +app = Starlette(routes=routes, middleware=middleware) +``` + +## HTTPSRedirectMiddleware + +Enforces that all incoming requests must either be `https` or `wss`. Any incoming +requests to `http` or `ws` will be redirected to the secure scheme instead. + +```python +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware + +routes = ... + +middleware = [ + Middleware(HTTPSRedirectMiddleware) +] + +app = Starlette(routes=routes, middleware=middleware) +``` + +There are no configuration options for this middleware class. + +## TrustedHostMiddleware + +Enforces that all incoming requests have a correctly set `Host` header, in order +to guard against HTTP Host Header attacks. + +```python +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.trustedhost import TrustedHostMiddleware + +routes = ... + +middleware = [ + Middleware(TrustedHostMiddleware, allowed_hosts=['example.com', '*.example.com']) +] + +app = Starlette(routes=routes, middleware=middleware) +``` + +The following arguments are supported: + +* `allowed_hosts` - A list of domain names that should be allowed as hostnames. Wildcard +domains such as `*.example.com` are supported for matching subdomains. To allow any +hostname either use `allowed_hosts=["*"]` or omit the middleware. + +If an incoming request does not validate correctly then a 400 response will be sent. + +## GZipMiddleware + +Handles GZip responses for any request that includes `"gzip"` in the `Accept-Encoding` header. + +The middleware will handle both standard and streaming responses. + +```python +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.gzip import GZipMiddleware + + +routes = ... + +middleware = [ + Middleware(GZipMiddleware, minimum_size=1000) +] + +app = Starlette(routes=routes, middleware=middleware) +``` + +The following arguments are supported: + +* `minimum_size` - Do not GZip responses that are smaller than this minimum size in bytes. Defaults to `500`. + +The middleware won't GZip responses that already have a `Content-Encoding` set, to prevent them from being encoded twice. + +## BaseHTTPMiddleware + +An abstract class that allows you to write ASGI middleware against a request/response +interface. + +### Usage + +To implement a middleware class using `BaseHTTPMiddleware`, you must override the +`async def dispatch(request, call_next)` method. + +```python +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware + + +class CustomHeaderMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + response = await call_next(request) + response.headers['Custom'] = 'Example' + return response + +routes = ... + +middleware = [ + Middleware(CustomHeaderMiddleware) +] + +app = Starlette(routes=routes, middleware=middleware) +``` + +If you want to provide configuration options to the middleware class you should +override the `__init__` method, ensuring that the first argument is `app`, and +any remaining arguments are optional keyword arguments. Make sure to set the `app` +attribute on the instance if you do this. + +```python +class CustomHeaderMiddleware(BaseHTTPMiddleware): + def __init__(self, app, header_value='Example'): + super().__init__(app) + self.header_value = header_value + + async def dispatch(self, request, call_next): + response = await call_next(request) + response.headers['Custom'] = self.header_value + return response + + +middleware = [ + Middleware(CustomHeaderMiddleware, header_value='Customized') +] + +app = Starlette(routes=routes, middleware=middleware) +``` + +Middleware classes should not modify their state outside of the `__init__` method. +Instead you should keep any state local to the `dispatch` method, or pass it +around explicitly, rather than mutating the middleware instance. + +### Limitations + +Currently, the `BaseHTTPMiddleware` has some known limitations: + +- Using `BaseHTTPMiddleware` will prevent changes to [`contextlib.ContextVar`](https://docs.python.org/3/library/contextvars.html#contextvars.ContextVar)s from propagating upwards. That is, if you set a value for a `ContextVar` in your endpoint and try to read it from a middleware you will find that the value is not the same value you set in your endpoint (see [this test](https://github.com/encode/starlette/blob/621abc747a6604825190b93467918a0ec6456a24/tests/middleware/test_base.py#L192-L223) for an example of this behavior). + +To overcome these limitations, use [pure ASGI middleware](#pure-asgi-middleware), as shown below. + +## Pure ASGI Middleware + +The [ASGI spec](https://asgi.readthedocs.io/en/latest/) makes it possible to implement ASGI middleware using the ASGI interface directly, as a chain of ASGI applications that call into the next one. In fact, this is how middleware classes shipped with Starlette are implemented. + +This lower-level approach provides greater control over behavior and enhanced interoperability across frameworks and servers. It also overcomes the [limitations of `BaseHTTPMiddleware`](#limitations). + +### Writing pure ASGI middleware + +The most common way to create an ASGI middleware is with a class. + +```python +class ASGIMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + await self.app(scope, receive, send) +``` + +The middleware above is the most basic ASGI middleware. It receives a parent ASGI application as an argument for its constructor, and implements an `async __call__` method which calls into that parent application. + +Some implementations such as [`asgi-cors`](https://github.com/simonw/asgi-cors/blob/10ef64bfcc6cd8d16f3014077f20a0fb8544ec39/asgi_cors.py) use an alternative style, using functions: + +```python +import functools + +def asgi_middleware(): + def asgi_decorator(app): + + @functools.wraps(app) + async def wrapped_app(scope, receive, send): + await app(scope, receive, send) + + return wrapped_app + + return asgi_decorator +``` + +In any case, ASGI middleware must be callables that accept three arguments: `scope`, `receive`, and `send`. + +* `scope` is a dict holding information about the connection, where `scope["type"]` may be: + * [`"http"`](https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope): for HTTP requests. + * [`"websocket"`](https://asgi.readthedocs.io/en/latest/specs/www.html#websocket-connection-scope): for WebSocket connections. + * [`"lifespan"`](https://asgi.readthedocs.io/en/latest/specs/lifespan.html#scope): for ASGI lifespan messages. +* `receive` and `send` can be used to exchange ASGI event messages with the ASGI server — more on this below. The type and contents of these messages depend on the scope type. Learn more in the [ASGI specification](https://asgi.readthedocs.io/en/latest/specs/index.html). + +### Using pure ASGI middleware + +Pure ASGI middleware can be used like any other middleware: + +```python +from starlette.applications import Starlette +from starlette.middleware import Middleware + +from .middleware import ASGIMiddleware + +routes = ... + +middleware = [ + Middleware(ASGIMiddleware), +] + +app = Starlette(..., middleware=middleware) +``` + +See also [Using middleware](#using-middleware). + +### Type annotations + +There are two ways of annotating a middleware: using Starlette itself or [`asgiref`](https://github.com/django/asgiref). + +* Using Starlette: for most common use cases. + +```python +from starlette.types import ASGIApp, Message, Scope, Receive, Send + + +class ASGIMiddleware: + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + return await self.app(scope, receive, send) + + async def send_wrapper(message: Message) -> None: + # ... Do something + await send(message) + + await self.app(scope, receive, send_wrapper) +``` + +* Using [`asgiref`](https://github.com/django/asgiref): for more rigorous type hinting. + +```python +from asgiref.typing import ASGI3Application, ASGIReceiveCallable, ASGISendCallable, Scope +from asgiref.typing import ASGIReceiveEvent, ASGISendEvent + + +class ASGIMiddleware: + def __init__(self, app: ASGI3Application) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + async def send_wrapper(message: ASGISendEvent) -> None: + # ... Do something + await send(message) + + return await self.app(scope, receive, send_wrapper) +``` + +### Common patterns + +#### Processing certain requests only + +ASGI middleware can apply specific behavior according to the contents of `scope`. + +For example, to only process HTTP requests, write this... + +```python +class ASGIMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + ... # Do something here! + + await self.app(scope, receive, send) +``` + +Likewise, WebSocket-only middleware would guard on `scope["type"] != "websocket"`. + +The middleware may also act differently based on the request method, URL, headers, etc. + +#### Reusing Starlette components + +Starlette provides several data structures that accept the ASGI `scope`, `receive` and/or `send` arguments, allowing you to work at a higher level of abstraction. Such data structures include [`Request`](requests.md#request), [`Headers`](requests.md#headers), [`QueryParams`](requests.md#query-parameters), [`URL`](requests.md#url), etc. + +For example, you can instantiate a `Request` to more easily inspect an HTTP request: + +```python +from starlette.requests import Request + +class ASGIMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + request = Request(scope) + ... # Use `request.method`, `request.url`, `request.headers`, etc. + + await self.app(scope, receive, send) +``` + +You can also reuse [responses](responses.md), which are ASGI applications as well. + +#### Sending eager responses + +Inspecting the connection `scope` allows you to conditionally call into a different ASGI app. One use case might be sending a response without calling into the app. + +As an example, this middleware uses a dictionary to perform permanent redirects based on the requested path. This could be used to implement ongoing support of legacy URLs in case you need to refactor route URL patterns. + +```python +from starlette.datastructures import URL +from starlette.responses import RedirectResponse + +class RedirectsMiddleware: + def __init__(self, app, path_mapping: dict): + self.app = app + self.path_mapping = path_mapping + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + url = URL(scope=scope) + + if url.path in self.path_mapping: + url = url.replace(path=self.path_mapping[url.path]) + response = RedirectResponse(url, status_code=301) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) +``` + +Example usage would look like this: + +```python +from starlette.applications import Starlette +from starlette.middleware import Middleware + +routes = ... + +redirections = { + "/v1/resource/": "/v2/resource/", + # ... +} + +middleware = [ + Middleware(RedirectsMiddleware, path_mapping=redirections), +] + +app = Starlette(routes=routes, middleware=middleware) +``` + + +#### Inspecting or modifying the request + +Request information can be accessed or changed by manipulating the `scope`. For a full example of this pattern, see Uvicorn's [`ProxyHeadersMiddleware`](https://github.com/encode/uvicorn/blob/fd4386fefb8fe8a4568831a7d8b2930d5fb61455/uvicorn/middleware/proxy_headers.py) which inspects and tweaks the `scope` when serving behind a frontend proxy. + +Besides, wrapping the `receive` ASGI callable allows you to access or modify the HTTP request body by manipulating [`http.request`](https://asgi.readthedocs.io/en/latest/specs/www.html#request-receive-event) ASGI event messages. + +As an example, this middleware computes and logs the size of the incoming request body... + +```python +class LoggedRequestBodySizeMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + body_size = 0 + + async def receive_logging_request_body_size(): + nonlocal body_size + + message = await receive() + assert message["type"] == "http.request" + + body_size += len(message.get("body", b"")) + + if not message.get("more_body", False): + print(f"Size of request body was: {body_size} bytes") + + return message + + await self.app(scope, receive_logging_request_body_size, send) +``` + +Likewise, WebSocket middleware may manipulate [`websocket.receive`](https://asgi.readthedocs.io/en/latest/specs/www.html#receive-receive-event) ASGI event messages to inspect or alter incoming WebSocket data. + +For an example that changes the HTTP request body, see [`msgpack-asgi`](https://github.com/florimondmanca/msgpack-asgi). + +#### Inspecting or modifying the response + +Wrapping the `send` ASGI callable allows you to inspect or modify the HTTP response sent by the underlying application. To do so, react to [`http.response.start`](https://asgi.readthedocs.io/en/latest/specs/www.html#response-start-send-event) or [`http.response.body`](https://asgi.readthedocs.io/en/latest/specs/www.html#response-body-send-event) ASGI event messages. + +As an example, this middleware adds some fixed extra response headers: + +```python +from starlette.datastructures import MutableHeaders + +class ExtraResponseHeadersMiddleware: + def __init__(self, app, headers): + self.app = app + self.headers = headers + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await self.app(scope, receive, send) + + async def send_with_extra_headers(message): + if message["type"] == "http.response.start": + headers = MutableHeaders(scope=message) + for key, value in self.headers: + headers.append(key, value) + + await send(message) + + await self.app(scope, receive, send_with_extra_headers) +``` + +See also [`asgi-logger`](https://github.com/Kludex/asgi-logger/blob/main/asgi_logger/middleware.py) for an example that inspects the HTTP response and logs a configurable HTTP access log line. + +Likewise, WebSocket middleware may manipulate [`websocket.send`](https://asgi.readthedocs.io/en/latest/specs/www.html#send-send-event) ASGI event messages to inspect or alter outgoing WebSocket data. + +Note that if you change the response body, you will need to update the response `Content-Length` header to match the new response body length. See [`brotli-asgi`](https://github.com/fullonic/brotli-asgi) for a complete example. + +#### Passing information to endpoints + +If you need to share information with the underlying app or endpoints, you may store it into the `scope` dictionary. Note that this is a convention -- for example, Starlette uses this to share routing information with endpoints -- but it is not part of the ASGI specification. If you do so, be sure to avoid conflicts by using keys that have low chances of being used by other middleware or applications. + +For example, when including the middleware below, endpoints would be able to access `request.scope["asgi_transaction_id"]`. + +```python +import uuid -You can combine this ASGI application with Starlette's routing to provide -comprehensive static file serving. +class TransactionIDMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + scope["asgi_transaction_id"] = uuid.uuid4() + await self.app(scope, receive, send) +``` + +#### Cleanup and error handling + +You can wrap the application in a `try/except/finally` block or a context manager to perform cleanup operations or do error handling. + +For example, the following middleware might collect metrics and process application exceptions... + +```python +import time + +class MonitoringMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + start = time.time() + try: + await self.app(scope, receive, send) + except Exception as exc: + ... # Process the exception + raise + finally: + end = time.time() + elapsed = end - start + ... # Submit `elapsed` as a metric to a monitoring backend +``` + +See also [`timing-asgi`](https://github.com/steinnes/timing-asgi) for a full example of this pattern. + +### Gotchas + +#### ASGI middleware should be stateless + +Because ASGI is designed to handle concurrent requests, any connection-specific state should be scoped to the `__call__` implementation. Not doing so would typically lead to conflicting variable reads/writes across requests, and most likely bugs. + +As an example, this would conditionally replace the response body, if an `X-Mock` header is present in the response... + +=== "✅ Do" + + ```python + from starlette.datastructures import Headers + + class MockResponseBodyMiddleware: + def __init__(self, app, content): + self.app = app + self.content = content + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + # A flag that we will turn `True` if the HTTP response + # has the 'X-Mock' header. + # ✅: Scoped to this function. + should_mock = False + + async def maybe_send_with_mock_content(message): + nonlocal should_mock + + if message["type"] == "http.response.start": + headers = Headers(raw=message["headers"]) + should_mock = headers.get("X-Mock") == "1" + await send(message) + + elif message["type"] == "http.response.body": + if should_mock: + message = {"type": "http.response.body", "body": self.content} + await send(message) + + await self.app(scope, receive, maybe_send_with_mock_content) + ``` + +=== "❌ Don't" + + ```python hl_lines="7-8" + from starlette.datastructures import Headers + + class MockResponseBodyMiddleware: + def __init__(self, app, content): + self.app = app + self.content = content + # ❌: This variable would be read and written across requests! + self.should_mock = False + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + async def maybe_send_with_mock_content(message): + if message["type"] == "http.response.start": + headers = Headers(raw=message["headers"]) + self.should_mock = headers.get("X-Mock") == "1" + await send(message) + + elif message["type"] == "http.response.body": + if self.should_mock: + message = {"type": "http.response.body", "body": self.content} + await send(message) + + await self.app(scope, receive, maybe_send_with_mock_content) + ``` + +See also [`GZipMiddleware`](https://github.com/encode/starlette/blob/9ef1b91c9c043197da6c3f38aa153fd874b95527/starlette/middleware/gzip.py) for a full example implementation that navigates this potential gotcha. + +### Further reading + +This documentation should be enough to have a good basis on how to create an ASGI middleware. + +Nonetheless, there are great articles about the subject: + +- [Introduction to ASGI: Emergence of an Async Python Web Ecosystem](https://florimond.dev/en/posts/2019/08/introduction-to-asgi-async-python-web/) +- [How to write ASGI middleware](https://pgjones.dev/blog/how-to-write-asgi-middleware-2021/) + +## Using middleware in other frameworks + +To wrap ASGI middleware around other ASGI applications, you should use the +more general pattern of wrapping the application instance: + +```python +app = TrustedHostMiddleware(app, allowed_hosts=['example.com']) +``` + +You can do this with a Starlette application instance too, but it is preferable +to use the `middleware=` style, as it will: + +* Ensure that everything remains wrapped in a single outermost `ServerErrorMiddleware`. +* Preserves the top-level `app` instance. + +## Applying middleware to groups of routes + +Middleware can also be added to `Mount` instances, which allows you to apply middleware to a group of routes or a sub-application: ```python from starlette.applications import Starlette -from starlette.routing import Mount -from starlette.staticfiles import StaticFiles +from starlette.middleware import Middleware +from starlette.middleware.gzip import GZipMiddleware +from starlette.routing import Mount, Route routes = [ - ... - Mount('/static', app=StaticFiles(directory='static'), name="static"), + Mount( + "/", + routes=[ + Route( + "/example", + endpoint=..., + ) + ], + middleware=[Middleware(GZipMiddleware)] + ) ] app = Starlette(routes=routes) ``` -Static files will respond with "404 Not found" or "405 Method not allowed" -responses for requests which do not match. In HTML mode if `404.html` file -exists it will be shown as 404 response. +Note that middleware used in this way is *not* wrapped in exception handling middleware like the middleware applied to the `Starlette` application is. +This is often not a problem because it only applies to middleware that inspect or modify the `Response`, and even then you probably don't want to apply this logic to error responses. +If you do want to apply the middleware logic to error responses only on some routes you have a couple of options: + +* Add an `ExceptionMiddleware` onto the `Mount` +* Add a `try/except` block to your middleware and return an error response from there +* Split up marking and processing into two middlewares, one that gets put on `Mount` which marks the response as needing processing (for example by setting `scope["log-response"] = True`) and another applied to the `Starlette` application that does the heavy lifting. -The `packages` option can be used to include "static" directories contained within -a python package. The Python "bootstrap4" package is an example of this. +The `Route`/`WebSocket` class also accepts a `middleware` argument, which allows you to apply middleware to a single route: ```python from starlette.applications import Starlette -from starlette.routing import Mount -from starlette.staticfiles import StaticFiles +from starlette.middleware import Middleware +from starlette.middleware.gzip import GZipMiddleware +from starlette.routing import Route -routes=[ - ... - Mount('/static', app=StaticFiles(directory='static', packages=['bootstrap4']), name="static"), +routes = [ + Route( + "/example", + endpoint=..., + middleware=[Middleware(GZipMiddleware)] + ) ] app = Starlette(routes=routes) ``` -By default `StaticFiles` will look for `statics` directory in each package, -you can change the default directory by specifying a tuple of strings. +You can also apply middleware to the `Router` class, which allows you to apply middleware to a group of routes: ```python -routes=[ - ... - Mount('/static', app=StaticFiles(packages=[('bootstrap4', 'static')]), name="static"), +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.gzip import GZipMiddleware +from starlette.routing import Route, Router + + +routes = [ + Route("/example", endpoint=...), + Route("/another", endpoint=...), ] + +router = Router(routes=routes, middleware=[Middleware(GZipMiddleware)]) ``` -You may prefer to include static files directly inside the "static" directory -rather than using Python packaging to include static files, but it can be useful -for bundling up reusable components. +## Third party middleware + +#### [asgi-auth-github](https://github.com/simonw/asgi-auth-github) + +This middleware adds authentication to any ASGI application, requiring users to sign in +using their GitHub account (via [OAuth](https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/)). +Access can be restricted to specific users or to members of specific GitHub organizations or teams. + +#### [asgi-csrf](https://github.com/simonw/asgi-csrf) + +Middleware for protecting against CSRF attacks. This middleware implements the Double Submit Cookie pattern, where a cookie is set, then it is compared to a csrftoken hidden form field or an `x-csrftoken` HTTP header. + +#### [AuthlibMiddleware](https://github.com/aogier/starlette-authlib) + +A drop-in replacement for Starlette session middleware, using [authlib's jwt](https://docs.authlib.org/en/latest/jose/jwt.html) +module. + +#### [BugsnagMiddleware](https://github.com/ashinabraham/starlette-bugsnag) + +A middleware class for logging exceptions to [Bugsnag](https://www.bugsnag.com/). + +#### [CSRFMiddleware](https://github.com/frankie567/starlette-csrf) + +Middleware for protecting against CSRF attacks. This middleware implements the Double Submit Cookie pattern, where a cookie is set, then it is compared to an `x-csrftoken` HTTP header. + +#### [EarlyDataMiddleware](https://github.com/HarrySky/starlette-early-data) + +Middleware and decorator for detecting and denying [TLSv1.3 early data](https://tools.ietf.org/html/rfc8470) requests. + +#### [PrometheusMiddleware](https://github.com/perdy/starlette-prometheus) + +A middleware class for capturing Prometheus metrics related to requests and responses, including in progress requests, timing... + +#### [ProxyHeadersMiddleware](https://github.com/encode/uvicorn/blob/master/uvicorn/middleware/proxy_headers.py) + +Uvicorn includes a middleware class for determining the client IP address, +when proxy servers are being used, based on the `X-Forwarded-Proto` and `X-Forwarded-For` headers. For more complex proxy configurations, you might want to adapt this middleware. + +#### [RateLimitMiddleware](https://github.com/abersheeran/asgi-ratelimit) + +A rate limit middleware. Regular expression matches url; flexible rules; highly customizable. Very easy to use. + +#### [RequestIdMiddleware](https://github.com/snok/asgi-correlation-id) + +A middleware class for reading/generating request IDs and attaching them to application logs. + +#### [RollbarMiddleware](https://docs.rollbar.com/docs/starlette) + +A middleware class for logging exceptions, errors, and log messages to [Rollbar](https://www.rollbar.com). + +#### [StarletteOpentracing](https://github.com/acidjunk/starlette-opentracing) + +A middleware class that emits tracing info to [OpenTracing.io](https://opentracing.io/) compatible tracers and +can be used to profile and monitor distributed applications. + +#### [SecureCookiesMiddleware](https://github.com/thearchitector/starlette-securecookies) + +Customizable middleware for adding automatic cookie encryption and decryption to Starlette applications, with +extra support for existing cookie-based middleware. + +#### [TimingMiddleware](https://github.com/steinnes/timing-asgi) + +A middleware class to emit timing information (cpu and wall time) for each request which +passes through it. Includes examples for how to emit these timings as statsd metrics. + +#### [WSGIMiddleware](https://github.com/abersheeran/a2wsgi) -[pathlike]: https://docs.python.org/3/library/os.html#os.PathLike +A middleware class in charge of converting a WSGI application into an ASGI one. diff --git a/starlette/middleware/sessions.py b/starlette/middleware/sessions.py index 1093717b4..e8a0b33ac 100644 --- a/starlette/middleware/sessions.py +++ b/starlette/middleware/sessions.py @@ -1,15 +1,60 @@ import json import typing from base64 import b64decode, b64encode +from datetime import datetime, timedelta, timezone import itsdangerous -from itsdangerous.exc import BadSignature +from itsdangerous.exc import BadSignature, SignatureExpired from starlette.datastructures import MutableHeaders, Secret from starlette.requests import HTTPConnection from starlette.types import ASGIApp, Message, Receive, Scope, Send +# mutable mapping that keeps track of whether it has been modified +class ModifiedDict(typing.Dict[str, typing.Any]): + def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: + super().__init__(*args, **kwargs) + self.modify = False + self.invalid = False + + def __setitem__(self, key: str, value: typing.Any) -> None: # pragma: no cover + super().__setitem__(key, value) + self.modify = True + + def __delitem__(self, key: str) -> None: # pragma: no cover + super().__delitem__(key) + self.modify = True + + def clear(self) -> None: + super().clear() + self.invalid = True + self.modify = True + + def pop( + self, key: str, default: typing.Any = None + ) -> typing.Any: # pragma: no cover + value = super().pop(key, default) + self.modify = True + return value + + def popitem(self) -> typing.Any: # pragma: no cover + value = super().popitem() + self.modify = True + return value + + def setdefault( + self, key: str, default: typing.Any = None + ) -> typing.Any: # pragma: no cover + value = super().setdefault(key, default) + self.modify = True + return value + + def update(self, *args: typing.Any, **kwargs: typing.Any) -> None: + super().update(*args, **kwargs) + self.modify = True + + class SessionMiddleware: def __init__( self, @@ -17,21 +62,50 @@ def __init__( secret_key: typing.Union[str, Secret], session_cookie: str = "session", max_age: typing.Optional[int] = 14 * 24 * 60 * 60, # 14 days, in seconds + refresh_window: typing.Optional[int] = None, path: str = "/", same_site: typing.Literal["lax", "strict", "none"] = "lax", https_only: bool = False, domain: typing.Optional[str] = None, + partitioned: typing.Optional[bool] = False, ) -> None: self.app = app self.signer = itsdangerous.TimestampSigner(str(secret_key)) self.session_cookie = session_cookie self.max_age = max_age + self.refresh_window = refresh_window self.path = path self.security_flags = "httponly; samesite=" + same_site if https_only: # Secure flag can be used with HTTPS only self.security_flags += "; secure" if domain is not None: self.security_flags += f"; domain={domain}" + if partitioned: + self.security_flags += "; partitioned" + + # Decode and validate cookie + def decode_cookie(self, cookie: bytes) -> ModifiedDict: + result: ModifiedDict = ModifiedDict() + try: + data = self.signer.unsign( + cookie, max_age=self.max_age, return_timestamp=True + ) + result = ModifiedDict(json.loads(b64decode(data[0]))) + except (BadSignature, SignatureExpired): + result.invalid = True + return result + + # data[1] is the datetime when signed from itsdangerous + if self.refresh_window and self.max_age: + now = datetime.now(timezone.utc) + expiration = data[1] + timedelta(seconds=self.max_age) + # The cookie is with in the refresh window, trigger a refresh. + if ( + now >= (expiration - timedelta(seconds=self.refresh_window)) + and now <= expiration + ): # noqa E501 + result.modify = True + return result async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] not in ("http", "websocket"): # pragma: no cover @@ -39,45 +113,44 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: return connection = HTTPConnection(scope) - initial_session_was_empty = True if self.session_cookie in connection.cookies: - data = connection.cookies[self.session_cookie].encode("utf-8") - try: - data = self.signer.unsign(data, max_age=self.max_age) - scope["session"] = json.loads(b64decode(data)) - initial_session_was_empty = False - except BadSignature: - scope["session"] = {} + scope["session"] = self.decode_cookie( + connection.cookies[self.session_cookie].encode("utf-8") + ) # noqa E501 else: - scope["session"] = {} + scope["session"] = ModifiedDict() async def send_wrapper(message: Message) -> None: if message["type"] == "http.response.start": - if scope["session"]: - # We have session data to persist. - data = b64encode(json.dumps(scope["session"]).encode("utf-8")) - data = self.signer.sign(data) + if scope["session"] and not scope["session"].invalid: + # Scope has session data and is valid. + if scope["session"].modify: + # Scope has updated data or needs refreshing. + data = b64encode(json.dumps(scope["session"]).encode("utf-8")) + data = self.signer.sign(data) + headers = MutableHeaders(scope=message) + header_value = "{session_cookie}={data}; path={path}; {max_age}{security_flags}".format( # noqa E501 + session_cookie=self.session_cookie, + data=data.decode("utf-8"), + path=self.path, + max_age=f"Max-Age={self.max_age}; " if self.max_age else "", + security_flags=self.security_flags, + ) + headers.append("Set-Cookie", header_value) + # If the session cookie is invalid for any reason + elif scope["session"].invalid: # Clear the cookie. headers = MutableHeaders(scope=message) header_value = "{session_cookie}={data}; path={path}; {max_age}{security_flags}".format( # noqa E501 - session_cookie=self.session_cookie, - data=data.decode("utf-8"), - path=self.path, - max_age=f"Max-Age={self.max_age}; " if self.max_age else "", - security_flags=self.security_flags, - ) - headers.append("Set-Cookie", header_value) - elif not initial_session_was_empty: - # The session has been cleared. - headers = MutableHeaders(scope=message) - header_value = "{session_cookie}={data}; path={path}; {expires}{security_flags}".format( # noqa E501 session_cookie=self.session_cookie, data="null", path=self.path, - expires="expires=Thu, 01 Jan 1970 00:00:00 GMT; ", + max_age="Max-Age=-1; ", security_flags=self.security_flags, ) headers.append("Set-Cookie", header_value) + # No session cookie was present, or it isn't modified, + # don't modify or delete the cookie. await send(message) await self.app(scope, receive, send_wrapper) diff --git a/tests/middleware/test_session.py b/tests/middleware/test_session.py index dc1094117..ea7b2ce00 100644 --- a/tests/middleware/test_session.py +++ b/tests/middleware/test_session.py @@ -1,4 +1,5 @@ import re +import time from starlette.applications import Starlette from starlette.middleware import Middleware @@ -202,3 +203,82 @@ def test_domain_cookie(test_client_factory): client.cookies.delete("session") response = client.get("/view_session") assert response.json() == {"session": {}} + + +def test_session_refresh(test_client_factory): + app = Starlette( + routes=[ + Route("/view_session", endpoint=view_session), + Route("/update_session", endpoint=update_session, methods=["POST"]), + ], + middleware=[ + Middleware( + SessionMiddleware, refresh_window=100, secret_key="example", max_age=100 + ) + ], + ) + + client = test_client_factory(app) + response = client.post("/update_session", json={"some": "data"}) + assert response.json() == {"session": {"some": "data"}} + original_cookie_header = response.headers["set-cookie"] + + # itsdangerous only signs with seconds resolution, no milliseconds. + time.sleep(1) + + response = client.get("/view_session") + assert response.json() == {"session": {"some": "data"}} + + second_cookie_header = response.headers["set-cookie"] + # second cookie data should match what was set and the signature is differnt. + assert original_cookie_header != second_cookie_header + + +def test_session_persistence(test_client_factory): + app = Starlette( + routes=[ + Route("/view_session", endpoint=view_session), + Route("/update_session", endpoint=update_session, methods=["POST"]), + ], + middleware=[Middleware(SessionMiddleware, secret_key="example", max_age=100)], + ) + + client = test_client_factory(app) + response = client.post("/update_session", json={"some": "data"}) + + assert response.json() == {"session": {"some": "data"}} + + response = client.get("/view_session") + # response includes the cookie data, and there's no new set-cookie + assert response.json() == { + "session": {"some": "data"} + } and not response.headers.get("set-cookie") + + +def test_partitioned_session(test_client_factory): + session_cookie = "__Host-session" + app = Starlette( + routes=[ + Route("/view_session", endpoint=view_session), + Route("/update_session", endpoint=update_session, methods=["POST"]), + ], + middleware=[ + Middleware( + SessionMiddleware, + secret_key="example", + https_only=True, + partitioned=True, + session_cookie=session_cookie, + same_site="none", + ) + ], + ) + + secure_client = test_client_factory(app, base_url="https://testserver") + + response = secure_client.post("/update_session", json={"some": "data"}) + assert response.json() == {"session": {"some": "data"}} + + cookie = response.headers["set-cookie"] + cookie_partition_match = re.search(rf"{session_cookie}.*; partitioned", cookie) + assert cookie_partition_match is not None