1use crate::constant_time_eq;
18use crate::middleware::{SessionExt, SessionHandle};
19use crate::session::Session;
20use rustlavel_core::{Error, Json};
21use rustlavel_http::handler::BoxFuture;
22use rustlavel_http::middleware::{Middleware, Next};
23use rustlavel_http::response::IntoResponse;
24use rustlavel_http::{Method, Request, Response, Status};
25
26pub const TOKEN_FIELD: &str = Session::TOKEN_KEY;
28
29pub const TOKEN_HEADER: &str = "x-csrf-token";
31
32pub const REJECTED: Status = Status(419);
38
39#[derive(Debug, Clone, Default)]
41pub struct Csrf {
42 except: Vec<String>,
43}
44
45impl Csrf {
46 pub fn new() -> Self {
47 Csrf::default()
48 }
49
50 pub fn except(mut self, prefix: impl Into<String>) -> Self {
56 self.except.push(prefix.into());
57 self
58 }
59
60 fn is_exempt(&self, path: &str) -> bool {
61 self.except.iter().any(|prefix| path.starts_with(prefix.as_str()))
62 }
63}
64
65fn is_safe(method: Method) -> bool {
70 matches!(method, Method::Get | Method::Head | Method::Options)
71}
72
73pub fn token(request: &Request) -> Option<String> {
75 request.try_session().map(SessionHandle::token)
76}
77
78pub fn field(request: &Request) -> String {
83 match token(request) {
84 Some(token) => format!(r#"<input type="hidden" name="{TOKEN_FIELD}" value="{token}">"#),
85 None => String::new(),
86 }
87}
88
89impl Middleware for Csrf {
90 fn handle(&self, mut request: Request, next: Next) -> BoxFuture<Response> {
91 let middleware = self.clone();
92
93 Box::pin(async move {
94 let Some(session) = request.try_session().cloned() else {
95 return Error::msg(
96 "the `csrf` middleware needs a session. Register the `session` middleware \
97 before it: `router.middleware(SessionManager::from_config(&config, store)?)`.",
98 )
99 .into_response();
100 };
101
102 let expected = session.token();
105
106 if is_safe(request.method()) || middleware.is_exempt(request.path()) {
107 return next.run(request).await;
108 }
109
110 let from_body = request.input(TOKEN_FIELD);
111 let presented = match from_body {
112 Some(token) => Some(token),
113 None => request.header(TOKEN_HEADER).map(str::to_string),
114 };
115
116 let accepted = presented
119 .is_some_and(|token| constant_time_eq(token.as_bytes(), expected.as_bytes()));
120
121 if accepted { next.run(request).await } else { rejection(&request) }
122 })
123 }
124}
125
126fn rejection(request: &Request) -> Response {
127 if request.wants_json() {
128 return Response::new(REJECTED).with_json(Json::object([(
129 "message",
130 Json::from("CSRF token mismatch."),
131 )]));
132 }
133
134 Response::new(REJECTED).with_html(format!(
135 "<h1>{} {}</h1><p>Your session has expired. Please refresh the page and try again.</p>",
136 REJECTED.code(),
137 REJECTED.reason()
138 ))
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::key::AppKey;
145 use crate::middleware::SessionManager;
146 use crate::store::MemoryStore;
147 use rustlavel_http::{Router, TestClient, TestResponse};
148
149 fn router() -> Router {
150 let mut router = Router::new();
151 router.middleware(SessionManager::new(&AppKey::from_bytes([5u8; 32]), MemoryStore::new()));
152 router.middleware(Csrf::new().except("/webhooks/"));
153
154 router.get("/form", |request: Request| async move { field(&request) });
155 router.post("/posts", |_request: Request| async { "created" });
156 router.put("/posts/1", |_request: Request| async { "updated" });
157 router.patch("/posts/1", |_request: Request| async { "patched" });
158 router.delete("/posts/1", |_request: Request| async { "deleted" });
159 router.post("/webhooks/stripe", |_request: Request| async { "accepted" });
160 router
161 }
162
163 fn cookie_of(response: &TestResponse) -> String {
165 response
166 .header("set-cookie")
167 .expect("the session middleware should have set a cookie")
168 .split(';')
169 .next()
170 .unwrap()
171 .to_string()
172 }
173
174 async fn open_form(client: &TestClient) -> (String, String) {
176 let response = client.get("/form").await.assert_ok();
177 let body = response.body();
178 let token = body
179 .split("value=\"")
180 .nth(1)
181 .and_then(|rest| rest.split('"').next())
182 .expect("the form should contain a token")
183 .to_string();
184
185 (token, cookie_of(&response))
186 }
187
188 #[tokio::test]
189 async fn a_get_request_passes_through_and_is_handed_a_token() {
190 let client = TestClient::new(router());
191 let (token, _) = open_form(&client).await;
192
193 assert_eq!(token.len(), 64, "the token should be 32 bytes of entropy in hex");
194 assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
195 }
196
197 #[tokio::test]
198 async fn a_post_without_a_token_is_rejected_with_419() {
199 TestClient::new(router())
200 .post("/posts", &[("title", "hello")])
201 .await
202 .assert_status(419)
203 .assert_see("expired");
204 }
205
206 #[tokio::test]
207 async fn a_post_with_the_session_token_is_accepted() {
208 let client = TestClient::new(router());
209 let (token, cookie) = open_form(&client).await;
210
211 let response = client
212 .send(
213 Request::new(Method::Post, "/posts")
214 .with_header("cookie", cookie)
215 .with_form(&[("title", "hello"), (TOKEN_FIELD, &token)]),
216 )
217 .await;
218
219 response.assert_ok().assert_see("created");
220 }
221
222 #[tokio::test]
223 async fn the_token_may_arrive_in_a_header_instead() {
224 let client = TestClient::new(router());
225 let (token, cookie) = open_form(&client).await;
226
227 client
228 .send(
229 Request::new(Method::Post, "/posts")
230 .with_header("cookie", cookie)
231 .with_header(TOKEN_HEADER, token)
232 .with_json(Json::object([("title", "hello".into())])),
233 )
234 .await
235 .assert_ok();
236 }
237
238 #[tokio::test]
239 async fn a_token_from_another_session_is_rejected() {
240 let client = TestClient::new(router());
243 let other_browser = TestClient::new(router());
244
245 let (token, _) = open_form(&client).await;
246 let (_, other_cookie) = open_form(&other_browser).await;
247
248 client
249 .send(
250 Request::new(Method::Post, "/posts")
251 .with_header("cookie", other_cookie)
252 .with_form(&[(TOKEN_FIELD, &token)]),
253 )
254 .await
255 .assert_status(419);
256 }
257
258 #[tokio::test]
259 async fn a_wrong_or_truncated_token_is_rejected() {
260 let client = TestClient::new(router());
261 let (token, cookie) = open_form(&client).await;
262
263 let candidates = [
264 String::new(),
265 token[..token.len() - 1].to_string(),
266 format!("{token}0"),
267 "a".repeat(64),
268 ];
269
270 for candidate in &candidates {
271 client
272 .send(
273 Request::new(Method::Post, "/posts")
274 .with_header("cookie", cookie.clone())
275 .with_form(&[(TOKEN_FIELD, candidate.as_str())]),
276 )
277 .await
278 .assert_status(419);
279 }
280 }
281
282 #[tokio::test]
283 async fn every_state_changing_method_is_guarded() {
284 let client = TestClient::new(router());
285
286 client.send(Request::new(Method::Put, "/posts/1")).await.assert_status(419);
287 client.send(Request::new(Method::Patch, "/posts/1")).await.assert_status(419);
288 client.delete("/posts/1").await.assert_status(419);
289 }
290
291 #[tokio::test]
292 async fn exempt_paths_are_left_alone() {
293 TestClient::new(router())
294 .post("/webhooks/stripe", &[("event", "paid")])
295 .await
296 .assert_ok()
297 .assert_see("accepted");
298 }
299
300 #[tokio::test]
301 async fn a_json_client_is_rejected_with_json() {
302 TestClient::new(router())
303 .post_json("/posts", Json::object([("title", "hello".into())]))
304 .await
305 .assert_status(419)
306 .assert_json("message", "CSRF token mismatch.");
307 }
308
309 #[tokio::test]
310 async fn the_token_is_stable_for_the_life_of_the_session() {
311 let client = TestClient::new(router());
312 let (first, cookie) = open_form(&client).await;
313
314 let response = client
315 .send(Request::new(Method::Get, "/form").with_header("cookie", cookie))
316 .await;
317 let second = response.body();
318
319 assert!(second.contains(&first), "the token must not change between page loads");
320 }
321
322 #[tokio::test]
323 async fn without_the_session_middleware_the_error_says_so() {
324 let mut router = Router::new();
325 router.middleware(Csrf::new());
326 router.get("/", |_request: Request| async { "ok" });
327
328 TestClient::new(router).get("/").await.assert_status(500);
331 }
332
333 #[test]
334 fn safe_methods_are_the_ones_with_no_side_effects() {
335 assert!(is_safe(Method::Get));
336 assert!(is_safe(Method::Head));
337 assert!(is_safe(Method::Options));
338 assert!(!is_safe(Method::Post));
339 assert!(!is_safe(Method::Put));
340 assert!(!is_safe(Method::Patch));
341 assert!(!is_safe(Method::Delete));
342 }
343}