|
| 1 | +# CSRF Protection |
| 2 | + |
| 3 | +[CSRFMiddleware](../../middleware.md#csrfmiddleware) protects your application against Cross‑Site Request Forgery (CSRF) |
| 4 | +using the **double‑submit cookie** pattern. It is secure by default and now supports **traditional HTML forms** without JavaScript, |
| 5 | +in addition to the header‑based approach commonly used by XHR/fetch. |
| 6 | + |
| 7 | +## Quick Start |
| 8 | + |
| 9 | +```python |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +from lilya.apps import Lilya |
| 13 | +from lilya.middleware import DefineMiddleware |
| 14 | +from lilya.middleware.csrf import CSRFMiddleware |
| 15 | + |
| 16 | +routes = [...] |
| 17 | + |
| 18 | +# Minimal setup |
| 19 | +app = Lilya( |
| 20 | + routes=routes, |
| 21 | + middleware=[ |
| 22 | + DefineMiddleware( |
| 23 | + CSRFMiddleware, |
| 24 | + secret="your-long-unique-secret", |
| 25 | + # Optional (see below): |
| 26 | + # form_field_name="csrf_token", |
| 27 | + # max_body_size=2 * 1024 * 1024, |
| 28 | + # httponly=False, # set False if templates must read cookie value |
| 29 | + # secure=True, # enable in production (HTTPS) |
| 30 | + # samesite="lax", |
| 31 | + ) |
| 32 | + ], |
| 33 | +) |
| 34 | +``` |
| 35 | + |
| 36 | +!!! Tip "Using settings" |
| 37 | + You can also configure the middleware via your settings `LILYA_SETTINGS_MODULE`. See the [Settings](../../settings.md) section. |
| 38 | + |
| 39 | +## How it Works |
| 40 | + |
| 41 | +On **safe methods** (by default `GET`, `HEAD`): |
| 42 | + |
| 43 | +* If the CSRF cookie (default name: `csrftoken`) is **missing**, the middleware **injects** it into the response. |
| 44 | + |
| 45 | +On **unsafe methods** (`POST`, `PUT`, `PATCH`, `DELETE`): |
| 46 | + |
| 47 | +1. The middleware first checks the **header**: `X‑CSRFToken`. |
| 48 | +2. If the header is missing and the body is a **form** (`application/x-www-form-urlencoded` or `multipart/form-data`), it will: |
| 49 | + * **Buffer** the request body, |
| 50 | + * Extract the CSRF token from a hidden field (default name: `csrf_token`), |
| 51 | + * **Replay** the exact same body to the downstream app, so handlers can still call `await request.form()` or `await request.body()` without change. |
| 52 | +3. It then validates that the submitted token (header or form) **matches the cookie**. |
| 53 | + |
| 54 | +Tokens are signed and compared in constant time. The middleware delegates token generation and verification to the shared utilities in `lilya.contrib.security.csrf`. |
| 55 | + |
| 56 | +## Configuration |
| 57 | + |
| 58 | +### Parameters |
| 59 | + |
| 60 | +```python |
| 61 | +CSRFMiddleware( |
| 62 | + app: ASGIApp, |
| 63 | + secret: str, |
| 64 | + *, |
| 65 | + cookie_name: str | None = "csrftoken", |
| 66 | + header_name: str | None = "X-CSRFToken", |
| 67 | + cookie_path: str | None = "/", |
| 68 | + safe_methods: set[str] | None = {"GET", "HEAD"}, |
| 69 | + secure: bool = False, |
| 70 | + httponly: bool = False, |
| 71 | + samesite: Literal["lax", "strict", "none"] = "lax", |
| 72 | + domain: str | None = None, |
| 73 | + |
| 74 | + # New |
| 75 | + form_field_name: str = "csrf_token", |
| 76 | + max_body_size: int = 2 * 1024 * 1024, |
| 77 | +) |
| 78 | +``` |
| 79 | + |
| 80 | +* **secret** *(required)*: Server key to HMAC‑sign tokens. |
| 81 | +* **cookiename**: Name of the CSRF cookie (default: `csrftoken`). |
| 82 | +* **headername**: Header for XHR/fetch token (default: `X‑CSRFToken`). |
| 83 | +* **safemethods**: Methods that skip CSRF validation (default: `{"GET", "HEAD"}`). |
| 84 | +* **secure / httponly / samesite / domain / cookiepath**: Cookie attributes. |
| 85 | + * Set **`secure=True`** in production. |
| 86 | + * Set **`httponly=False`** if your templates need to **read** the cookie (for hidden inputs). |
| 87 | +* **formfieldname** *(new)*: Hidden input field name to read token from when header is absent (default: `csrf_token`). |
| 88 | +* **maxbodysize** *(new)*: Safety cap for buffering request bodies during form fallback (default: 2 MiB). |
| 89 | + |
| 90 | +## Real‑World Usage |
| 91 | + |
| 92 | +### 1. Classic HTML Forms (no JavaScript) |
| 93 | + |
| 94 | +```python |
| 95 | +from lilya.apps import Lilya |
| 96 | +from lilya.middleware import DefineMiddleware |
| 97 | +from lilya.middleware.csrf import CSRFMiddleware |
| 98 | +from lilya.responses import HTML, Ok |
| 99 | +from lilya.requests import Request |
| 100 | +from lilya.routing import Path |
| 101 | +from lilya.contrib.security.csrf import get_or_set_csrf_token |
| 102 | + |
| 103 | +HTML_TEMPLATE = """ |
| 104 | +/login |
| 105 | + <input type="text" name="username" /> |
| 106 | + <input type="password" name="password" /> |
| 107 | + <input type="hidden" name="csrf_token" value="{token}" /> |
| 108 | + <button type="submit">Login</button> |
| 109 | +</form> |
| 110 | +""" |
| 111 | + |
| 112 | +async def get_login(request: Request): |
| 113 | + # Prepare the response first so we can attach Set-Cookie before sending |
| 114 | + response = HTML(HTML_TEMPLATE.format(token="")) |
| 115 | + token = get_or_set_csrf_token( |
| 116 | + request, response, secret="your-long-unique-secret", httponly=False |
| 117 | + ) |
| 118 | + response.body = HTML_TEMPLATE.format(token=token).encode("utf-8") |
| 119 | + return response |
| 120 | + |
| 121 | +async def post_login(request: Request): |
| 122 | + form = await request.form() |
| 123 | + return Ok({"username": form.get("username")}) |
| 124 | + |
| 125 | +app = Lilya( |
| 126 | + routes=[Path("/login", get_login, methods=["GET"]), Path("/login", post_login, methods=["POST"])], |
| 127 | + middleware=[DefineMiddleware(CSRFMiddleware, secret="your-long-unique-secret", httponly=False)], |
| 128 | +) |
| 129 | +``` |
| 130 | + |
| 131 | +**Why `httponly=False`?** The template must read the CSRF cookie to render it in the hidden field. |
| 132 | +If you exclusively use the **header** approach, you can keep `httponly=True`. |
| 133 | + |
| 134 | +### 2. XHR / fetch & SPA/HTMX |
| 135 | + |
| 136 | +```javascript |
| 137 | +async function postData(url, data) { |
| 138 | + // Grab the CSRF cookie (csrftoken=...) |
| 139 | + const csrf = document.cookie |
| 140 | + .split("; ") |
| 141 | + .find((c) => c.startsWith("csrftoken=")) |
| 142 | + ?.split("=")[1]; |
| 143 | + |
| 144 | + const res = await fetch(url, { |
| 145 | + method: "POST", |
| 146 | + headers: { |
| 147 | + "Content-Type": "application/json", |
| 148 | + "X-CSRFToken": csrf, // <-- header-based token |
| 149 | + }, |
| 150 | + credentials: "same-origin", |
| 151 | + body: JSON.stringify(data), |
| 152 | + }); |
| 153 | + return res.json(); |
| 154 | +} |
| 155 | +``` |
| 156 | +
|
| 157 | +!!! info "Observation" |
| 158 | + This path is ideal for SPAs, HTMX, and progressive enhancement—no need to read the cookie in templates. |
| 159 | +
|
| 160 | +### 3. File Upload Forms (multipart) |
| 161 | +
|
| 162 | +Add a hidden field with the token. The middleware understands `multipart/form-data`: |
| 163 | +
|
| 164 | +### 4. Custom Hidden Field Name |
| 165 | +
|
| 166 | +Prefer a different name (e.g., `csrfmiddlewaretoken`)? Configure it: |
| 167 | +
|
| 168 | +```python |
| 169 | +DefineMiddleware( |
| 170 | + CSRFMiddleware, |
| 171 | + secret="your-long-unique-secret", |
| 172 | + form_field_name="csrfmiddlewaretoken", |
| 173 | + httponly=False, |
| 174 | +) |
| 175 | +``` |
| 176 | +
|
| 177 | +## CSRF Utilities (`lilya.contrib.security.csrf`) |
| 178 | +
|
| 179 | +To keep things DRY, the middleware uses shared helpers. You can also use them directly in views, tests, or custom flows. |
| 180 | +
|
| 181 | +```python |
| 182 | +from lilya.contrib.security.csrf import ( |
| 183 | + generate_csrf_token, |
| 184 | + decode_csrf_token, |
| 185 | + tokens_match, |
| 186 | + build_csrf_cookie, |
| 187 | + ensure_csrf_cookie, |
| 188 | + get_or_set_csrf_token, |
| 189 | +) |
| 190 | +``` |
| 191 | +
|
| 192 | +### Common Helpers |
| 193 | +
|
| 194 | +* `generate_csrf_token(secret: str) -> str` - Returns a new signed CSRF token. |
| 195 | +
|
| 196 | +* `decode_csrf_token(secret: str, token: str) -> str | None` - Validates and returns the token's secret part or `None`. |
| 197 | +
|
| 198 | +* `tokens_match(secret: str, a: str | None, b: str | None) -> bool` - Constant‑time check: tokens are valid and represent the same underlying value. |
| 199 | +
|
| 200 | +* `build_csrf_cookie(...) -> Cookie` - Builds a `Cookie` instance prefilled with a fresh token. |
| 201 | +
|
| 202 | +* `ensure_csrf_cookie(response, secret, **cookie_opts) -> str` - Adds a CSRF cookie to the response if you need one **immediately**. Returns the token value. |
| 203 | +
|
| 204 | +* `get_or_set_csrf_token(request, response, secret, **cookie_opts) -> str` - Returns the existing CSRF cookie value if present, otherwise |
| 205 | +sets a new one and returns it—perfect for first‑time GETs that render forms. |
| 206 | +
|
| 207 | +## Advanced Topics |
| 208 | +
|
| 209 | +### Body Replay (Form Fallback) |
| 210 | +
|
| 211 | +When the header is absent and the request body is a form, the middleware buffers the body to extract the hidden field and then **replays** the same body to your app. This guarantees downstream code can still read the body normally: |
| 212 | +
|
| 213 | +```python |
| 214 | +async def handler(request): |
| 215 | + form = await request.form() # works even if middleware parsed earlier |
| 216 | + ... |
| 217 | +``` |
| 218 | +
|
| 219 | +### Large Bodies |
| 220 | +
|
| 221 | +Parsing is capped by `max_body_size`. If exceeded, the middleware **skips** fallback parsing and the request will fail CSRF unless a header token is provided. |
| 222 | +
|
| 223 | +```python |
| 224 | +DefineMiddleware( |
| 225 | + CSRFMiddleware, |
| 226 | + secret="...", |
| 227 | + max_body_size=64 * 1024, # 64 KiB for small forms |
| 228 | +) |
| 229 | +``` |
| 230 | +
|
| 231 | +## Security Notes & Best Practices |
| 232 | +
|
| 233 | +* **Always enable `secure=True`** in production so the cookie is only sent over HTTPS. |
| 234 | +* **`HttpOnly`**: |
| 235 | + - Keep `httponly=True` if you use the **header** path exclusively (you don't need to read the cookie in templates). |
| 236 | + - Set `httponly=False` if you **render the token** into a hidden form field from the cookie. |
| 237 | +* **`SameSite`**: `lax` is a sane default for most apps; adjust for your cross‑site embed needs. |
| 238 | +* **Scope**: Use `cookie_path="/"` unless you want tokens limited to a sub‑path. |
| 239 | +* **Rotate secret** carefully—revoking all tokens may temporarily fail outstanding form submissions. |
| 240 | +
|
| 241 | +## Troubleshooting |
| 242 | +
|
| 243 | +**403: CSRF token verification failed** |
| 244 | +
|
| 245 | +* Missing cookie? Ensure a prior `GET` set it, or call `get_or_set_csrf_token` in your GET handler. |
| 246 | +* Header missing? If you're using XHR/fetch, send `X‑CSRFToken`. |
| 247 | +* Using classic forms? Ensure you render a hidden input named `csrf_token` (or your custom `form_field_name`) with the **exact cookie value**. |
| 248 | +* Large body? Increase `max_body_size` or provide the token via header. |
| 249 | +* Different domains/subdomains? Check cookie `domain` and `samesite` settings. |
| 250 | +
|
| 251 | +## A quick example "how to" |
| 252 | +
|
| 253 | +Let us go through a quick example how to practically use this. We will be using Jinja for it as well |
| 254 | +as the [TemplateController](../../templates.md#templatecontroller) to make it easier to show. |
| 255 | +
|
| 256 | +Feel free to use whatever you want. |
| 257 | +
|
| 258 | +**The HTML** |
| 259 | +
|
| 260 | +```html title="login.html" |
| 261 | +<!doctype html> |
| 262 | +<html> |
| 263 | + <body> |
| 264 | + <h1>Login</h1> |
| 265 | + <form action="." method="POST"> |
| 266 | + <label>Username <input type="text" name="username" required></label><br> |
| 267 | + <label>Password <input type="password" name="password" required></label><br> |
| 268 | + <!-- Hidden CSRF field --> |
| 269 | + <input type="hidden" name="csrf_token" value="{{ token }}"> |
| 270 | + <button type="submit">Login</button> |
| 271 | + </form> |
| 272 | + </body> |
| 273 | +</html> |
| 274 | +``` |
| 275 | +
|
| 276 | +**The handler or Controller** |
| 277 | +
|
| 278 | +Now its time for the handler. |
| 279 | +
|
| 280 | +```python |
| 281 | +from typing import Any |
| 282 | + |
| 283 | +from lilya.requests import Request |
| 284 | +from lilya.responses import HTML, Ok |
| 285 | +from lilya.contrib.security.csrf import get_or_set_csrf_token |
| 286 | +from lilya.templating.controllers import TemplateController |
| 287 | + |
| 288 | +CSRF_SECRET = "change-me-long-random" # from settings in real apps |
| 289 | + |
| 290 | +class LoginController(TemplateController): |
| 291 | + template_name = "login.html" |
| 292 | + |
| 293 | + async def get_context_data(self, request: Request, **kwargs) -> Any: |
| 294 | + """ |
| 295 | + Add the token to the context that is automatically |
| 296 | + injected by the `TemplateController` of Lilya |
| 297 | + """ |
| 298 | + context = await super().get_context_data(request, **kwargs) |
| 299 | + |
| 300 | + # Get or generate the CSRF Token |
| 301 | + token = get_or_set_csrf_token( |
| 302 | + request, |
| 303 | + response, |
| 304 | + secret=CSRF_SECRET, |
| 305 | + # You can keep HttpOnly=True because we're not reading the cookie in JS; |
| 306 | + # we render the token directly into HTML from the server. |
| 307 | + httponly=True, |
| 308 | + ) |
| 309 | + context.update({ |
| 310 | + "token": token |
| 311 | + }) |
| 312 | + return context |
| 313 | +
|
| 314 | + async def get(self, request: Request) -> HTML: |
| 315 | + return await self.render_template(request) |
| 316 | + |
| 317 | + async def post(self, request: Request) -> HTML: |
| 318 | + # Get the form |
| 319 | + form = await request.form() |
| 320 | +
|
| 321 | + # Do things and return |
| 322 | + ... |
| 323 | + |
| 324 | + # Return your HTML response |
| 325 | +``` |
| 326 | +
|
| 327 | +**The application** |
| 328 | +
|
| 329 | +```python |
| 330 | +from lilya.apps import Lilya |
| 331 | +from lilya.routing import Path |
| 332 | +from lilya.middleware import DefineMiddleware |
| 333 | +from lilya.middleware.csrf import CSRFMiddleware |
| 334 | +
|
| 335 | +
|
| 336 | +app = Lilya( |
| 337 | + routes=[ |
| 338 | + Path("/login", LoginController, name="login"), |
| 339 | + ], |
| 340 | + middleware=[ |
| 341 | + DefineMiddleware( |
| 342 | + CSRFMiddleware, |
| 343 | + secret=CSRF_SECRET, |
| 344 | + secure=False, # True in production (HTTPS) |
| 345 | + samesite="lax", |
| 346 | + httponly=True, |
| 347 | + ) |
| 348 | + ], |
| 349 | +) |
| 350 | +``` |
| 351 | +
|
| 352 | +Because we embed the server‑generated token directly, we can keep the cookie **HttpOnly** (more secure), since the browser JS doesn't need to read it. |
| 353 | + |
| 354 | +!!! Tip "Observation" |
| 355 | + The reason why we use TemplateController its because its cleaner and more organised for this |
| 356 | + example but you are free to use functions if you are more comfortable with. |
| 357 | + |
| 358 | +## What the middleware does vs. what you must do |
| 359 | + |
| 360 | +**Automatically done by `CSRFMiddleware`:** |
| 361 | + |
| 362 | +* Sets the CSRF cookie on safe methods if missing. |
| 363 | +* On unsafe methods, validates: |
| 364 | + * `X‑CSRFToken` header **or** |
| 365 | + * hidden form field (fallback) in `application/x-www-form-urlencoded` or `multipart/form-data`. |
| 366 | +* Rejects invalid/missing tokens with `403 PermissionDenied`. |
| 367 | + |
| 368 | +**You still need to:** |
| 369 | + |
| 370 | +* **Include** the token in submissions: |
| 371 | + * Hidden field (classic forms), **or** |
| 372 | + * Header (XHR/fetch/HTMX). |
| 373 | +* On first page render, **ensure a token exists** and embed it: |
| 374 | + * Use `get_or_set_csrf_token(request, response, secret=...)` from `lilya.contrib.security.csrf` to set the cookie *and* get the token value. |
| 375 | +* Choose cookie flags: |
| 376 | + * **Recommended** for SSR: `httponly=True` (since you embed token directly in HTML). |
| 377 | + * For JS‑read cookie patterns, keep `httponly=False` (less secure; only if you need to read the cookie in JS). |
| 378 | + |
| 379 | +## Notes |
| 380 | + |
| 381 | +* **Validation is automatic** (the middleware will reject/allow unsafe requests). |
| 382 | +* **Supplying the token is *not* automatic**—your HTML must include the CSRF token either: |
| 383 | + * As a **hidden form field** (for classic forms), or |
| 384 | + * As an **HTTP header** (for XHR/fetch/HTMX). |
0 commit comments