1mod events;
13
14pub use events::CsrfMismatch;
15
16use http::Method;
17use sova_cookies::{CookieBuilder, ResponseCookieExt};
18use sova_core::extend::named;
19use sova_core::{with_state, App, Error, EventBus, Plugin, Request, Response};
20use sova_session::SessionExt;
21use std::collections::HashSet;
22use std::sync::Arc;
23
24const DEFAULT_SESSION_KEY: &str = "csrf";
25const DEFAULT_FIELD: &str = "csrf";
26const DEFAULT_HEADER: &str = "x-csrf-token";
27const DEFAULT_XSRF_COOKIE: &str = "XSRF-TOKEN";
28const DEFAULT_XSRF_HEADER: &str = "x-xsrf-token";
29
30#[derive(Clone, Debug)]
32pub struct CsrfToken(pub String);
33
34#[derive(Clone)]
36pub struct Csrf {
37 session_key: String,
38 field: String,
39 field_explicit: bool,
40 header: String,
41 header_explicit: bool,
42 auto: bool,
44 auto_explicit: bool,
45 except: Vec<String>,
47 only: Vec<String>,
49 methods: HashSet<Method>,
51 xsrf_cookie: bool,
52 xsrf_cookie_name: String,
53 xsrf_header: String,
54}
55
56impl Csrf {
57 pub fn new() -> Self {
58 Self {
59 session_key: DEFAULT_SESSION_KEY.into(),
60 field: DEFAULT_FIELD.into(),
61 field_explicit: false,
62 header: DEFAULT_HEADER.into(),
63 header_explicit: false,
64 auto: true,
65 auto_explicit: false,
66 except: Vec::new(),
67 only: Vec::new(),
68 methods: HashSet::new(),
69 xsrf_cookie: true,
70 xsrf_cookie_name: DEFAULT_XSRF_COOKIE.into(),
71 xsrf_header: DEFAULT_XSRF_HEADER.into(),
72 }
73 }
74
75 pub fn session_key(mut self, key: impl Into<String>) -> Self {
76 self.session_key = key.into();
77 self
78 }
79
80 pub fn field(mut self, name: impl Into<String>) -> Self {
82 self.field = name.into();
83 self.field_explicit = true;
84 self
85 }
86
87 pub fn header(mut self, name: impl Into<String>) -> Self {
89 self.header = name.into();
90 self.header_explicit = true;
91 self
92 }
93
94 pub fn auto(mut self, on: bool) -> Self {
96 self.auto = on;
97 self.auto_explicit = true;
98 self
99 }
100
101 pub fn skip(mut self, path: impl Into<String>) -> Self {
103 self.except.push(path.into());
104 self
105 }
106
107 pub fn except(mut self, path: impl Into<String>) -> Self {
109 self.except.push(path.into());
110 self
111 }
112
113 pub fn only(mut self, path: impl Into<String>) -> Self {
115 self.only.push(path.into());
116 self
117 }
118
119 pub fn methods(mut self, methods: impl IntoIterator<Item = Method>) -> Self {
123 self.methods = methods.into_iter().collect();
124 self
125 }
126
127 pub fn xsrf_cookie(mut self, on: bool) -> Self {
129 self.xsrf_cookie = on;
130 self
131 }
132
133 pub fn xsrf_cookie_name(mut self, name: impl Into<String>) -> Self {
134 self.xsrf_cookie_name = name.into();
135 self
136 }
137
138 pub fn xsrf_header(mut self, name: impl Into<String>) -> Self {
140 self.xsrf_header = name.into();
141 self
142 }
143}
144
145impl Default for Csrf {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151impl Plugin for Csrf {
152 fn id(&self) -> &'static str {
153 "csrf"
154 }
155
156 fn requires(&self) -> &'static [&'static str] {
157 &["session"]
158 }
159
160 fn meta(&self) -> sova_core::PluginMeta {
161 sova_core::PluginMeta::new("CSRF")
162 .description("Session double-submit CSRF (Laravel-style except/XSRF cookie)")
163 .version(env!("CARGO_PKG_VERSION"))
164 }
165
166 fn install(mut self, app: &mut App) {
167 if let Some(doc) = app.config_doc() {
168 if let Some(section) = doc.section("csrf") {
169 if !self.field_explicit {
170 if let Some(v) = section.get("field").and_then(|v| v.as_str()) {
171 self.field = v.to_string();
172 }
173 }
174 if !self.header_explicit {
175 if let Some(v) = section.get("header").and_then(|v| v.as_str()) {
176 self.header = v.to_string();
177 }
178 }
179 if !self.auto_explicit {
180 if let Some(v) = section.get("auto").and_then(|v| v.as_bool()) {
181 self.auto = v;
182 }
183 }
184 }
185 }
186 let cfg = Arc::new(self);
187 app.use_middleware(named(
188 "csrf",
189 with_state(cfg, |cfg, mut req, next| async move {
190 let token = ensure_token(&req, &cfg.session_key);
191 req.set(CsrfToken(token.clone()));
192
193 if cfg.auto
194 && method_checked(&req.method, &cfg.methods)
195 && path_checked(&req.path, cfg.as_ref())
196 {
197 match submitted_token(&mut req, cfg.as_ref()).await {
198 Ok(TokenFind::Match(got)) if tokens_equal(&got, &token) => {}
199 Ok(TokenFind::Match(_)) => {
200 emit_csrf_mismatch(&req);
201 return Error::custom(403, "csrf mismatch").into_response();
202 }
203 Ok(TokenFind::Missing) => {
204 emit_csrf_mismatch(&req);
205 return Error::custom(403, "missing csrf token").into_response();
206 }
207 Ok(TokenFind::Deferred) => {
208 }
210 Err(err) => return err.into_response(),
211 }
212 }
213
214 let mut res = next(req).await;
215 if cfg.xsrf_cookie {
216 res = attach_xsrf_cookie(res, &cfg.xsrf_cookie_name, &token);
217 }
218 res
219 }),
220 ));
221 }
222}
223
224fn emit_csrf_mismatch(req: &Request) {
225 if let Some(bus) = req.try_state::<EventBus>() {
226 bus.dispatch(CsrfMismatch {
227 method: req.method.to_string(),
228 path: req.path.clone(),
229 });
230 }
231}
232
233fn attach_xsrf_cookie(res: Response, name: &str, token: &str) -> Response {
234 let mut builder = CookieBuilder::build((name.to_string(), token.to_string())).path("/");
235 if xsrf_cookie_secure() {
236 builder = builder.secure(true);
237 }
238 res.cookie(builder.build())
239}
240
241fn xsrf_cookie_secure() -> bool {
242 let production = std::env::var("SOVA_ENV")
243 .map(|v| v.eq_ignore_ascii_case("production"))
244 .unwrap_or(false);
245 let forced = std::env::var("SESSION_SECURE")
246 .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes"))
247 .unwrap_or(false);
248 production || forced
249}
250
251pub trait CsrfExt {
253 fn csrf_token(&self) -> String;
255
256 fn verify_csrf(&self, submitted: Option<&str>) -> sova_core::Result<()>;
258}
259
260impl CsrfExt for Request {
261 fn csrf_token(&self) -> String {
262 if let Some(t) = self.get::<CsrfToken>() {
263 return t.0.clone();
264 }
265 ensure_token(self, DEFAULT_SESSION_KEY)
266 }
267
268 fn verify_csrf(&self, submitted: Option<&str>) -> sova_core::Result<()> {
269 let Some(got) = submitted.filter(|s| !s.is_empty()) else {
270 return Err(Error::BadRequest("missing csrf token".into()));
271 };
272 let expected = self.csrf_token();
273 if !tokens_equal(got, &expected) {
274 return Err(Error::BadRequest("csrf mismatch".into()));
275 }
276 Ok(())
277 }
278}
279
280fn ensure_token(req: &Request, session_key: &str) -> String {
281 if let Some(t) = req.session().get(session_key) {
282 if !t.is_empty() {
283 return t;
284 }
285 }
286 let t = new_token();
287 req.session().set(session_key, t.clone());
288 t
289}
290
291fn method_checked(method: &Method, configured: &HashSet<Method>) -> bool {
292 if configured.is_empty() {
293 return !matches!(
294 *method,
295 Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
296 );
297 }
298 configured.contains(method)
299}
300
301fn path_matches(path: &str, patterns: &[String]) -> bool {
302 patterns.iter().any(|p| {
303 if let Some(prefix) = p.strip_suffix('*') {
304 path.starts_with(prefix)
305 } else {
306 path == p
307 }
308 })
309}
310
311fn path_checked(path: &str, cfg: &Csrf) -> bool {
312 if path_matches(path, &cfg.except) {
313 return false;
314 }
315 if cfg.only.is_empty() {
316 return true;
317 }
318 path_matches(path, &cfg.only)
319}
320
321enum TokenFind {
322 Match(String),
323 Missing,
324 Deferred,
326}
327
328async fn submitted_token(req: &mut Request, cfg: &Csrf) -> sova_core::Result<TokenFind> {
329 if let Some(h) = req.header(&cfg.header).filter(|s| !s.is_empty()) {
330 return Ok(TokenFind::Match(h.to_string()));
331 }
332 if let Some(h) = req.header(&cfg.xsrf_header).filter(|s| !s.is_empty()) {
333 let decoded = urlencoding_decode(h);
335 return Ok(TokenFind::Match(decoded));
336 }
337 if let Some(q) = req.query(&cfg.field).filter(|s| !s.is_empty()) {
338 return Ok(TokenFind::Match(q.to_string()));
339 }
340
341 let ct = req.content_type().unwrap_or("");
342 if ct.eq_ignore_ascii_case("application/x-www-form-urlencoded") {
343 let bytes = req.body().await?;
344 if let Ok(map) = serde_urlencoded::from_bytes::<Vec<(String, String)>>(&bytes) {
345 if let Some((_, v)) = map.into_iter().find(|(k, _)| k == &cfg.field) {
346 if !v.is_empty() {
347 return Ok(TokenFind::Match(v));
348 }
349 }
350 }
351 return Ok(TokenFind::Missing);
352 }
353
354 if ct.starts_with("multipart/") {
355 return Ok(TokenFind::Deferred);
356 }
357
358 Ok(TokenFind::Missing)
359}
360
361fn urlencoding_decode(s: &str) -> String {
362 match percent_decode(s) {
364 Some(v) => v,
365 None => s.to_string(),
366 }
367}
368
369fn percent_decode(s: &str) -> Option<String> {
370 if !s.contains('%') {
371 return None;
372 }
373 let mut out = Vec::with_capacity(s.len());
374 let bytes = s.as_bytes();
375 let mut i = 0;
376 while i < bytes.len() {
377 if bytes[i] == b'%' && i + 2 < bytes.len() {
378 let h = from_hex(bytes[i + 1])?;
379 let l = from_hex(bytes[i + 2])?;
380 out.push((h << 4) | l);
381 i += 3;
382 } else if bytes[i] == b'+' {
383 out.push(b' ');
384 i += 1;
385 } else {
386 out.push(bytes[i]);
387 i += 1;
388 }
389 }
390 String::from_utf8(out).ok()
391}
392
393fn from_hex(b: u8) -> Option<u8> {
394 match b {
395 b'0'..=b'9' => Some(b - b'0'),
396 b'a'..=b'f' => Some(b - b'a' + 10),
397 b'A'..=b'F' => Some(b - b'A' + 10),
398 _ => None,
399 }
400}
401
402fn tokens_equal(a: &str, b: &str) -> bool {
403 if a.len() != b.len() {
404 return false;
405 }
406 a.bytes()
407 .zip(b.bytes())
408 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
409 == 0
410}
411
412fn new_token() -> String {
413 let mut buf = [0u8; 16];
414 if getrandom::getrandom(&mut buf).is_err() {
415 let t = std::time::SystemTime::now()
416 .duration_since(std::time::UNIX_EPOCH)
417 .map(|d| d.as_nanos())
418 .unwrap_or(0);
419 return format!("{t:032x}");
420 }
421 let mut s = String::with_capacity(32);
422 for b in buf {
423 s.push_str(&format!("{b:02x}"));
424 }
425 s
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use sova_core::Response;
432 use sova_session::memory_sessions;
433
434 fn app_with_csrf() -> App {
435 let mut app = App::new();
436 app.install(memory_sessions());
437 app.install(Csrf::new());
438 app.get("/", |req: Request| async move {
439 Response::text(req.csrf_token())
440 });
441 app.post("/echo", |_req: Request| async { Response::text("ok") });
442 app.put("/echo", |_req: Request| async { Response::text("ok") });
443 app
444 }
445
446 fn session_cookie(res: &Response) -> String {
447 res.headers()
448 .get_all("set-cookie")
449 .iter()
450 .filter_map(|v| v.to_str().ok())
451 .find(|c| c.starts_with("sova_sid="))
452 .map(|c| c.split(';').next().unwrap().to_string())
453 .expect("session cookie")
454 }
455
456 fn cookie_named(res: &Response, name: &str) -> Option<String> {
457 let prefix = format!("{name}=");
458 res.headers()
459 .get_all("set-cookie")
460 .iter()
461 .filter_map(|v| v.to_str().ok())
462 .find(|c| c.starts_with(&prefix))
463 .map(|c| {
464 c.split(';')
465 .next()
466 .unwrap()
467 .trim_start_matches(&prefix)
468 .to_string()
469 })
470 }
471
472 #[tokio::test]
473 async fn get_sets_token_and_xsrf_cookie() {
474 let app = app_with_csrf();
475 let res = app
476 .handle(Request::builder().method(Method::GET).path("/").build())
477 .await;
478 assert_eq!(res.status_code().as_u16(), 200);
479 let body = String::from_utf8_lossy(res.body_bytes().unwrap()).into_owned();
480 assert!(!body.is_empty());
481 assert_eq!(cookie_named(&res, "XSRF-TOKEN").as_deref(), Some(body.as_str()));
482 }
483
484 #[tokio::test]
485 async fn post_without_token_is_403() {
486 let app = app_with_csrf();
487 let get = app
488 .handle(Request::builder().method(Method::GET).path("/").build())
489 .await;
490 let cookie = session_cookie(&get);
491
492 let res = app
493 .handle(
494 Request::builder()
495 .method(Method::POST)
496 .path("/echo")
497 .header("cookie", &cookie)
498 .header("content-type", "application/x-www-form-urlencoded")
499 .body("x=1")
500 .build(),
501 )
502 .await;
503 assert_eq!(res.status_code().as_u16(), 403);
504 }
505
506 #[tokio::test]
507 async fn post_with_form_token_ok() {
508 let app = app_with_csrf();
509 let get = app
510 .handle(Request::builder().method(Method::GET).path("/").build())
511 .await;
512 let cookie = session_cookie(&get);
513 let get2 = app
514 .handle(
515 Request::builder()
516 .method(Method::GET)
517 .path("/")
518 .header("cookie", &cookie)
519 .build(),
520 )
521 .await;
522 let token = String::from_utf8_lossy(get2.body_bytes().unwrap()).into_owned();
523
524 let res = app
525 .handle(
526 Request::builder()
527 .method(Method::POST)
528 .path("/echo")
529 .header("cookie", &cookie)
530 .header("content-type", "application/x-www-form-urlencoded")
531 .body(format!("csrf={token}"))
532 .build(),
533 )
534 .await;
535 assert_eq!(res.status_code().as_u16(), 200);
536 }
537
538 #[tokio::test]
539 async fn post_with_xsrf_header_ok() {
540 let app = app_with_csrf();
541 let get = app
542 .handle(Request::builder().method(Method::GET).path("/").build())
543 .await;
544 let cookie = session_cookie(&get);
545 let token = cookie_named(&get, "XSRF-TOKEN").expect("xsrf");
546
547 let res = app
548 .handle(
549 Request::builder()
550 .method(Method::POST)
551 .path("/echo")
552 .header("cookie", &cookie)
553 .header("x-xsrf-token", &token)
554 .header("content-type", "application/json")
555 .body("{}")
556 .build(),
557 )
558 .await;
559 assert_eq!(res.status_code().as_u16(), 200);
560 }
561
562 #[tokio::test]
563 async fn except_skips_path() {
564 let mut app = App::new();
565 app.install(memory_sessions());
566 app.install(Csrf::new().except("/echo"));
567 app.post("/echo", |_req: Request| async { Response::text("ok") });
568
569 let res = app
570 .handle(
571 Request::builder()
572 .method(Method::POST)
573 .path("/echo")
574 .header("content-type", "application/json")
575 .body("{}")
576 .build(),
577 )
578 .await;
579 assert_eq!(res.status_code().as_u16(), 200);
580 }
581
582 #[tokio::test]
583 async fn only_limits_paths() {
584 let mut app = App::new();
585 app.install(memory_sessions());
586 app.install(Csrf::new().only("/secure/*"));
587 app.post("/open", |_req: Request| async { Response::text("ok") });
588 app.post("/secure/x", |_req: Request| async { Response::text("ok") });
589
590 let open = app
591 .handle(
592 Request::builder()
593 .method(Method::POST)
594 .path("/open")
595 .header("content-type", "application/json")
596 .body("{}")
597 .build(),
598 )
599 .await;
600 assert_eq!(open.status_code().as_u16(), 200);
601
602 let locked = app
603 .handle(
604 Request::builder()
605 .method(Method::POST)
606 .path("/secure/x")
607 .header("content-type", "application/json")
608 .body("{}")
609 .build(),
610 )
611 .await;
612 assert_eq!(locked.status_code().as_u16(), 403);
613 }
614
615 #[tokio::test]
616 async fn methods_filter() {
617 let mut app = App::new();
618 app.install(memory_sessions());
619 app.install(Csrf::new().methods([Method::DELETE]));
620 app.post("/echo", |_req: Request| async { Response::text("ok") });
621 app.delete("/echo", |_req: Request| async { Response::text("ok") });
622
623 let post = app
624 .handle(
625 Request::builder()
626 .method(Method::POST)
627 .path("/echo")
628 .header("content-type", "application/json")
629 .body("{}")
630 .build(),
631 )
632 .await;
633 assert_eq!(post.status_code().as_u16(), 200);
634
635 let del = app
636 .handle(
637 Request::builder()
638 .method(Method::DELETE)
639 .path("/echo")
640 .header("content-type", "application/json")
641 .body("{}")
642 .build(),
643 )
644 .await;
645 assert_eq!(del.status_code().as_u16(), 403);
646 }
647}