Skip to content

Commit 6ed451f

Browse files
authored
Change internals of AnonymousUser, CSRF and Internals (#289)
* Change internals of AnonymousUser * Update imports * Add token discovery from form in CRSF middleware * Add CSRF form field support * Add HTML response * Add security CSRF section
1 parent e5df699 commit 6ed451f

23 files changed

Lines changed: 1284 additions & 108 deletions

File tree

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
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

Comments
 (0)