Skip to main content

sova_csrf/
lib.rs

1//! CSRF protection via session double-submit (Laravel-style).
2//!
3//! Install after sessions. Mutating requests are checked for a matching token in
4//! (order): `X-CSRF-TOKEN` → `X-XSRF-TOKEN` → query field →
5//! `application/x-www-form-urlencoded` body field.
6//! Multipart bodies are left to handlers ([`CsrfExt::verify_csrf`]) unless the
7//! header/query carries the token.
8//!
9//! With [`Csrf::xsrf_cookie`] (default on), each response also gets a readable
10//! `XSRF-TOKEN` cookie so SPA clients (axios) can send `X-XSRF-TOKEN`.
11
12mod 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/// Per-request token placed by middleware / [`CsrfExt::csrf_token`].
31#[derive(Clone, Debug)]
32pub struct CsrfToken(pub String);
33
34/// Session double-submit CSRF plugin.
35#[derive(Clone)]
36pub struct Csrf {
37    session_key: String,
38    field: String,
39    field_explicit: bool,
40    header: String,
41    header_explicit: bool,
42    /// Reject checked methods without a valid token (default: true).
43    auto: bool,
44    auto_explicit: bool,
45    /// Paths excluded from auto-check (`/hook` exact, or `/api/*` prefix).
46    except: Vec<String>,
47    /// If non-empty, only these paths are checked (still minus `except`).
48    only: Vec<String>,
49    /// Methods that require a token. Empty → Laravel-style unsafe methods.
50    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    /// Form / JSON field name (default `csrf`).
81    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    /// Primary request header name (default `x-csrf-token`).
88    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    /// Disable automatic checks; use [`CsrfExt::verify_csrf`] in handlers.
95    pub fn auto(mut self, on: bool) -> Self {
96        self.auto = on;
97        self.auto_explicit = true;
98        self
99    }
100
101    /// Skip auto-check for a path (`/hook` exact, or `/api/*` prefix).
102    pub fn skip(mut self, path: impl Into<String>) -> Self {
103        self.except.push(path.into());
104        self
105    }
106
107    /// Laravel-style alias for [`Self::skip`].
108    pub fn except(mut self, path: impl Into<String>) -> Self {
109        self.except.push(path.into());
110        self
111    }
112
113    /// Only auto-check these paths (exact or `prefix*`). Empty = all paths (minus except).
114    pub fn only(mut self, path: impl Into<String>) -> Self {
115        self.only.push(path.into());
116        self
117    }
118
119    /// Restrict which HTTP methods require a token.
120    ///
121    /// Default (empty set): all methods except GET, HEAD, OPTIONS, TRACE.
122    pub fn methods(mut self, methods: impl IntoIterator<Item = Method>) -> Self {
123        self.methods = methods.into_iter().collect();
124        self
125    }
126
127    /// Set readable `XSRF-TOKEN` cookie on responses (default: true).
128    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    /// Alternate header read from the XSRF cookie (default `x-xsrf-token`).
139    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                            // multipart without header — handler must `verify_csrf`
209                        }
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
251/// Request helpers for CSRF tokens.
252pub trait CsrfExt {
253    /// Session CSRF token (creates one if missing).
254    fn csrf_token(&self) -> String;
255
256    /// Compare submitted value to the session token.
257    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    /// Multipart without header/query — leave check to the handler.
325    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        // Axios may send URL-encoded cookie value.
334        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    // Minimal decode for axios XSRF values; leave as-is if not encoded.
363    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}