larust_http/csrf.rs
1use crate::random::random_hex;
2use crate::session::Session;
3use axum::body::Body;
4use axum::extract::Request;
5use axum::http::{Method, StatusCode};
6use axum::middleware::Next;
7use axum::response::{IntoResponse, Response};
8
9const SESSION_KEY: &str = "_csrf_token";
10/// The form field / query param name a template's `@csrf` directive emits
11/// and this middleware checks - matches Laravel's own `_token` convention
12/// closely enough to be recognizable, kept distinct (`_csrf_token`) so it
13/// doesn't collide with an app's own `token` field.
14pub const FIELD_NAME: &str = "_csrf_token";
15/// The header a JS-driven request (`fetch`/`XMLHttpRequest`) can send the
16/// token in instead of a form field - matches Laravel's own `X-CSRF-TOKEN`
17/// convention, typically sourced from a `<meta name="csrf-token">` tag.
18/// `HeaderMap::get` is case-insensitive, so the exact casing here doesn't
19/// matter for matching, only for what a client sees if it inspects the name.
20pub const HEADER_NAME: &str = "X-CSRF-TOKEN";
21
22/// Returns the current session's CSRF token, generating and storing one on
23/// first use. Read this to embed the token in a form (the `@csrf`
24/// directive does this automatically for templates that pass `csrf_token`
25/// in their `view!` context).
26pub async fn token(session: &Session) -> String {
27 if let Ok(Some(existing)) = session.get::<String>(SESSION_KEY).await {
28 return existing;
29 }
30
31 let generated = generate_token();
32 // Best-effort: if the store write fails, the token is still returned
33 // and usable for this response, it just won't be persisted for the
34 // next request's verification - the next request will get a fresh
35 // token and any in-flight form submission naturally fails CSRF
36 // verification rather than silently succeeding.
37 if let Err(error) = session.insert(SESSION_KEY, generated.clone()).await {
38 tracing::warn!(%error, "failed to persist CSRF token to session store");
39 }
40 generated
41}
42
43fn generate_token() -> String {
44 random_hex(32)
45}
46
47/// Verifies the CSRF token on state-changing requests (POST/PUT/PATCH/
48/// DELETE) against the value stored in the session, rejecting with 419
49/// (Laravel's own status code for this) on mismatch.
50///
51/// Checks the `X-CSRF-TOKEN` header *first*, before touching the body at
52/// all - matching Laravel's own `VerifyCsrfToken` middleware, which checks
53/// this header ahead of a form field for exactly the same reason: a
54/// JS-driven request (`fetch`/`XMLHttpRequest`, not a submitted `<form>`)
55/// has nowhere else to put the token, and reading it needs no assumption
56/// about the body's content type at all. This is also what makes uploads
57/// through this middleware possible in the first place - a header-present
58/// request never reaches the body-read/2MB-cap path below, so a multipart
59/// file (routinely well over 2MB, and never form-urlencoded) isn't capped
60/// or misparsed here; the actual body is handled downstream by whatever
61/// extractor the route uses. If the header is present, it's the *only*
62/// source checked (present-but-wrong rejects immediately - this is
63/// deliberately not "check header, then also check body if that fails",
64/// which would make the two sources ambiguous about which is authoritative).
65///
66/// Falls back to reading the whole body and checking the submitted
67/// `_csrf_token` form field only when the header is absent - unchanged
68/// from before, still assumes `application/x-www-form-urlencoded` for that
69/// path (matching plain `<form>` submissions, the only thing that ever
70/// relied on it) - then reconstructs the request with the same bytes so
71/// downstream extractors (`FormRequest`) can still parse it normally.
72pub async fn verify(session: Session, request: Request, next: Next) -> Response {
73 let is_state_changing = matches!(
74 *request.method(),
75 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
76 );
77
78 if !is_state_changing {
79 return next.run(request).await;
80 }
81
82 let expected = token(&session).await;
83
84 if let Some(header_value) = request
85 .headers()
86 .get(HEADER_NAME)
87 .and_then(|value| value.to_str().ok())
88 {
89 return if header_value == expected {
90 next.run(request).await
91 } else {
92 reject()
93 };
94 }
95
96 let (parts, body) = request.into_parts();
97 let bytes = match axum::body::to_bytes(body, 2 * 1024 * 1024).await {
98 Ok(bytes) => bytes,
99 Err(_) => return (StatusCode::BAD_REQUEST, "invalid request body").into_response(),
100 };
101
102 let submitted = form_urlencoded::parse(&bytes)
103 .find(|(key, _)| key == FIELD_NAME)
104 .map(|(_, value)| value.into_owned());
105
106 if submitted.as_deref() != Some(expected.as_str()) {
107 return reject();
108 }
109
110 let request = Request::from_parts(parts, Body::from(bytes));
111 next.run(request).await
112}
113
114/// Real (if minimal) HTML, not bare text - a genuine resubmission of a
115/// stale form (a page rendered directly from a POST, not redirected after
116/// it, then refreshed) previously landed here with literally nothing else
117/// on the page: no navigation, no explanation, no way back except editing
118/// the URL bar by hand. `larust-http` has no dependency on `larust-core`
119/// (the reverse would be a cycle - `Application` depends on `Router`), so
120/// this can't reach into `error_pages`'s own branded shell; a small,
121/// self-contained page is the right scope here, not a new cross-crate
122/// architecture for one status code.
123fn reject() -> Response {
124 (
125 StatusCode::from_u16(419).expect("419 is a valid HTTP status code"),
126 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
127 r#"<!doctype html>
128<html lang="en">
129<head>
130<meta charset="utf-8">
131<meta name="viewport" content="width=device-width, initial-scale=1">
132<title>419 Page Expired</title>
133<style>
134 :root { color-scheme: light dark; }
135 body {
136 margin: 0; min-height: 100vh; display: flex; flex-direction: column;
137 align-items: center; justify-content: center; text-align: center;
138 padding: 40px 20px; gap: 12px;
139 font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
140 color-scheme: light dark;
141 }
142 h1 { margin: 0; font-size: 1.4rem; }
143 p { margin: 0; max-width: 40ch; opacity: .75; }
144 a {
145 margin-top: 8px; display: inline-block; padding: 10px 20px; border-radius: 10px;
146 font-weight: 700; color: #fff; background: #f4513d; text-decoration: none;
147 }
148</style>
149</head>
150<body>
151<h1>419 · Page Expired</h1>
152<p>This page took too long, or was already submitted once - your session's security token no longer matches.</p>
153<a href="/">Go back home</a>
154</body>
155</html>"#,
156 )
157 .into_response()
158}