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
12use 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/// Per-request token placed by middleware / [`CsrfExt::csrf_token`].
27#[derive(Clone, Debug)]
28pub struct CsrfToken(pub String);
29
30/// Session double-submit CSRF plugin.
31#[derive(Clone)]
32pub struct Csrf {
33    session_key: String,
34    field: String,
35    field_explicit: bool,
36    header: String,
37    header_explicit: bool,
38    /// Reject checked methods without a valid token (default: true).
39    auto: bool,
40    auto_explicit: bool,
41    /// Paths excluded from auto-check (`/hook` exact, or `/api/*` prefix).
42    except: Vec<String>,
43    /// If non-empty, only these paths are checked (still minus `except`).
44    only: Vec<String>,
45    /// Methods that require a token. Empty → Laravel-style unsafe methods.
46    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    /// Form / JSON field name (default `csrf`).
77    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    /// Primary request header name (default `x-csrf-token`).
84    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    /// Disable automatic checks; use [`CsrfExt::verify_csrf`] in handlers.
91    pub fn auto(mut self, on: bool) -> Self {
92        self.auto = on;
93        self.auto_explicit = true;
94        self
95    }
96
97    /// Skip auto-check for a path (`/hook` exact, or `/api/*` prefix).
98    pub fn skip(mut self, path: impl Into<String>) -> Self {
99        self.except.push(path.into());
100        self
101    }
102
103    /// Laravel-style alias for [`Self::skip`].
104    pub fn except(mut self, path: impl Into<String>) -> Self {
105        self.except.push(path.into());
106        self
107    }
108
109    /// Only auto-check these paths (exact or `prefix*`). Empty = all paths (minus except).
110    pub fn only(mut self, path: impl Into<String>) -> Self {
111        self.only.push(path.into());
112        self
113    }
114
115    /// Restrict which HTTP methods require a token.
116    ///
117    /// Default (empty set): all methods except GET, HEAD, OPTIONS, TRACE.
118    pub fn methods(mut self, methods: impl IntoIterator<Item = Method>) -> Self {
119        self.methods = methods.into_iter().collect();
120        self
121    }
122
123    /// Set readable `XSRF-TOKEN` cookie on responses (default: true).
124    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    /// Alternate header read from the XSRF cookie (default `x-xsrf-token`).
135    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                            // multipart without header — handler must `verify_csrf`
203                        }
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
236/// Request helpers for CSRF tokens.
237pub trait CsrfExt {
238    /// Session CSRF token (creates one if missing).
239    fn csrf_token(&self) -> String;
240
241    /// Compare submitted value to the session token.
242    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    /// Multipart without header/query — leave check to the handler.
310    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        // Axios may send URL-encoded cookie value.
319        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    // Minimal decode for axios XSRF values; leave as-is if not encoded.
348    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}