Skip to main content

ullun/
csrf.rs

1//! CSRF protection for cookie-authenticated forms and APIs
2//!
3//! [`Csrf`] needs the [`SessionLayer`](crate::session::SessionLayer) before it.
4//! Every request with an unsafe method (`POST`, `PUT`, `PATCH`, `DELETE`) must
5//! prove it was made by your own pages in two ways:
6//!
7//! 1. **Origin check**: an `Origin` header (or, without it, `Sec-Fetch-Site`) that
8//!    points to another site is rejected with `403`.
9//! 2. **Token**: the session's token, sent as the `x-csrf-token` header or as the
10//!    `csrf_token` field of an urlencoded or multipart form. Compared in
11//!    constant time.
12//!
13//! ```no_run
14//! use ullun::csrf::Csrf;
15//! use ullun::prelude::*;
16//! use ullun::session::SessionLayer;
17//!
18//! # async fn run() {
19//! App::new()
20//!     .middleware(SessionLayer::new())
21//!     .middleware(Csrf::new())
22//!     .get("/form", |req: Request| async move {
23//!         let token = req.session()?.csrf_token()?;
24//!         Ok(Response::html(format!(
25//!             r#"<form method="post" action="/save"><input type="hidden" name="csrf_token" value="{token}"><button>Save</button></form>"#
26//!         )))
27//!     })
28//!     .post("/save", |_req: Request| async { Ok(Response::text("saved")) })
29//!     .run("127.0.0.1:3000")
30//!     .await
31//!     .unwrap();
32//! # }
33//! ```
34//!
35//! Scripts read the token from a `<meta>` tag you render and send it as a
36//! header. Requests that authenticate with an `Authorization` header instead of
37//! cookies are not CSRF-prone: exempt them with [`Csrf::exempt`].
38
39use crate::error::{Error, Result};
40use crate::middleware::{Middleware, Next};
41use crate::request::Request;
42use crate::response::Response;
43use crate::security::constant_time_eq;
44use async_trait::async_trait;
45use std::sync::Arc;
46
47type Exempt = dyn Fn(&Request) -> bool + Send + Sync;
48
49/// CSRF middleware, see the [module docs](self)
50pub struct Csrf {
51    header: String,
52    field: String,
53    allowed_origins: Vec<String>,
54    exempt: Option<Arc<Exempt>>,
55}
56
57impl Csrf {
58    pub fn new() -> Self {
59        Self {
60            header: "x-csrf-token".into(),
61            field: "csrf_token".into(),
62            allowed_origins: Vec::new(),
63            exempt: None,
64        }
65    }
66
67    /// Name of the header carrying the token (default `x-csrf-token`)
68    pub fn header_name(mut self, name: impl Into<String>) -> Self {
69        self.header = name.into().to_ascii_lowercase();
70        self
71    }
72
73    /// Name of the form field carrying the token (default `csrf_token`)
74    pub fn field_name(mut self, name: impl Into<String>) -> Self {
75        self.field = name.into();
76        self
77    }
78
79    /// Accept requests from this origin too, e.g. `https://admin.example.com`
80    pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
81        self.allowed_origins.push(origin.into());
82        self
83    }
84
85    /// Skip all checks for requests where `f` returns true, e.g. API calls
86    /// authenticated by a bearer token instead of a cookie
87    pub fn exempt<F>(mut self, f: F) -> Self
88    where
89        F: Fn(&Request) -> bool + Send + Sync + 'static,
90    {
91        self.exempt = Some(Arc::new(f));
92        self
93    }
94
95    fn origin_ok(&self, req: &Request) -> bool {
96        let header = |name: &str| req.headers.get(name).and_then(|v| v.to_str().ok());
97        if let Some(origin) = header("origin") {
98            if self.allowed_origins.iter().any(|o| o == origin) {
99                return true;
100            }
101            // `null` (sandboxed frames, some redirects) is never ours
102            let authority = origin.split_once("://").map(|(_, rest)| rest);
103            return match (authority, header("host")) {
104                (Some(a), Some(h)) => a.eq_ignore_ascii_case(h),
105                _ => false,
106            };
107        }
108        match header("sec-fetch-site") {
109            Some("same-origin") | Some("none") | None => true,
110            Some(_) => false,
111        }
112    }
113
114    fn submitted_token(&self, req: &Request) -> Option<String> {
115        if let Some(t) = req
116            .headers
117            .get(self.header.as_str())
118            .and_then(|v| v.to_str().ok())
119        {
120            return Some(t.to_string());
121        }
122        let content_type = req
123            .headers
124            .get(http::header::CONTENT_TYPE)
125            .and_then(|v| v.to_str().ok())
126            .unwrap_or("")
127            .to_ascii_lowercase();
128        if content_type.starts_with("application/x-www-form-urlencoded") {
129            req.form_data().ok()?.get(&self.field).map(str::to_owned)
130        } else if content_type.starts_with("multipart/form-data") {
131            req.multipart().ok()?.text(&self.field).map(str::to_owned)
132        } else {
133            None
134        }
135    }
136}
137
138impl Default for Csrf {
139    fn default() -> Self {
140        Self::new()
141    }
142}
143
144#[async_trait]
145impl Middleware for Csrf {
146    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
147        let safe = matches!(
148            req.method,
149            http::Method::GET | http::Method::HEAD | http::Method::OPTIONS | http::Method::TRACE
150        );
151        if safe || self.exempt.as_ref().is_some_and(|f| f(&req)) {
152            return next.run(req).await;
153        }
154
155        if !self.origin_ok(&req) {
156            return Err(Error::forbidden("Cross-site request blocked"));
157        }
158        // 500 if the session layer is missing: that is a setup bug, not a client error
159        let expected: Option<String> = req.session()?.get("_csrf");
160        let submitted = self.submitted_token(&req);
161        match (expected, submitted) {
162            (Some(expected), Some(given))
163                if constant_time_eq(expected.as_bytes(), given.as_bytes()) =>
164            {
165                next.run(req).await
166            }
167            _ => Err(Error::forbidden("CSRF token missing or invalid")),
168        }
169    }
170}