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