Skip to main content

rws_server/csrf/
mod.rs

1//! CSRF protection middleware using the double-submit cookie pattern.
2//!
3//! Enabled by the `csrf` Cargo feature:
4//!
5//! ```toml
6//! rust-web-server = { version = "17", features = ["csrf"] }
7//! ```
8//!
9//! # Usage
10//!
11//! Add [`CsrfLayer`] to the middleware stack. It validates the CSRF token on
12//! every mutating request (`POST`, `PUT`, `PATCH`, `DELETE`) and passes safe
13//! methods through unconditionally.
14//!
15//! ```rust,no_run
16//! # extern crate rws_server as rust_web_server;
17//! use rust_web_server::app::App;
18//! use rust_web_server::core::New;
19//! use rust_web_server::csrf::CsrfLayer;
20//!
21//! let app = App::new().wrap(CsrfLayer::new());
22//! ```
23//!
24//! # Embedding the token in HTML forms
25//!
26//! In a `GET` handler, call [`CsrfToken::from_request`] to obtain the current
27//! token and embed it in the form:
28//!
29//! ```rust,no_run
30//! # extern crate rws_server as rust_web_server;
31//! use rust_web_server::core::New;
32//! use rust_web_server::csrf::CsrfToken;
33//! use rust_web_server::request::Request;
34//! use rust_web_server::response::Response;
35//! use rust_web_server::server::ConnectionInfo;
36//!
37//! fn show_form(req: &Request, _conn: &ConnectionInfo) -> Response {
38//!     let token = CsrfToken::from_request(req)
39//!         .map(|t| t.value().to_string())
40//!         .unwrap_or_default();
41//!     let html = format!(
42//!         r#"<form method="POST" action="/submit">
43//!   <input type="hidden" name="_csrf" value="{token}">
44//!   <button type="submit">Submit</button>
45//! </form>"#
46//!     );
47//!     Response::new()  // build your response with `html` body
48//! }
49//! ```
50//!
51//! For AJAX, include the token in the `X-CSRF-Token` request header. The cookie
52//! is not `HttpOnly` by default so that JavaScript can read it via
53//! `document.cookie`; call [`.http_only(true)`](CsrfLayer::http_only) to
54//! restrict to HTML-form workflows only.
55//!
56//! # How it works
57//!
58//! 1. On safe methods (`GET`/`HEAD`/`OPTIONS`): [`CsrfLayer`] reads the
59//!    existing `_csrf` cookie or generates a 32-byte random token. The token is
60//!    injected into the request (as an internal header) so [`CsrfToken::from_request`]
61//!    can return it to the handler. The cookie is (re-)set on the response.
62//!
63//! 2. On mutating methods: the layer reads the cookie value and the submitted
64//!    value (from the `X-CSRF-Token` header or the `_csrf` form field). It
65//!    compares them in **constant time** and returns `403 Forbidden` if they do
66//!    not match.
67
68#[cfg(test)]
69mod tests;
70
71use rand_core::{OsRng, RngCore};
72
73use crate::application::Application;
74use crate::error::{AppError, IntoResponse};
75use crate::header::Header;
76use crate::middleware::Middleware;
77use crate::request::Request;
78use crate::response::Response;
79use crate::server::ConnectionInfo;
80
81/// Internal header name used to pass the current CSRF token from
82/// [`CsrfLayer`] to [`CsrfToken::from_request`] without a separate store.
83const INJECTED_HEADER: &str = "X-Rws-Csrf-Token";
84
85/// CSRF protection middleware.
86///
87/// Add to any application with `.wrap(CsrfLayer::new())`.  Safe HTTP methods
88/// (`GET`, `HEAD`, `OPTIONS`, `TRACE`) always pass through; mutating methods
89/// are validated and rejected with `403 Forbidden` on mismatch.
90///
91/// # Defaults
92///
93/// | Setting | Default |
94/// |---|---|
95/// | Cookie name | `_csrf` |
96/// | Form field name | `_csrf` |
97/// | Validated header | `X-CSRF-Token` |
98/// | `SameSite` | `Strict` |
99/// | `HttpOnly` | `false` (JS-readable for AJAX workflows) |
100/// | `Secure` | `false` (enable in production behind HTTPS) |
101pub struct CsrfLayer {
102    cookie_name: String,
103    field_name: String,
104    header_name: String,
105    http_only: bool,
106    secure: bool,
107    same_site: String,
108}
109
110impl Default for CsrfLayer {
111    fn default() -> Self {
112        CsrfLayer {
113            cookie_name: "_csrf".to_string(),
114            field_name: "_csrf".to_string(),
115            header_name: "X-CSRF-Token".to_string(),
116            http_only: false,
117            secure: false,
118            same_site: "Strict".to_string(),
119        }
120    }
121}
122
123impl CsrfLayer {
124    /// Create a [`CsrfLayer`] with default settings.
125    pub fn new() -> Self {
126        Self::default()
127    }
128
129    /// Override the cookie name (default: `"_csrf"`).
130    pub fn cookie_name(mut self, name: &str) -> Self {
131        self.cookie_name = name.to_string();
132        self
133    }
134
135    /// Override the form field name (default: `"_csrf"`).
136    pub fn field_name(mut self, name: &str) -> Self {
137        self.field_name = name.to_string();
138        self
139    }
140
141    /// Override the request header name that carries the token for AJAX
142    /// (default: `"X-CSRF-Token"`).
143    pub fn header_name(mut self, name: &str) -> Self {
144        self.header_name = name.to_string();
145        self
146    }
147
148    /// Set the `HttpOnly` flag on the CSRF cookie (default: `false`).
149    ///
150    /// Enable this if you only serve traditional HTML forms and never need
151    /// JavaScript to read the token. When `true`, AJAX clients cannot access
152    /// the token via `document.cookie` and must rely on a server-rendered
153    /// token in the HTML.
154    pub fn http_only(mut self, http_only: bool) -> Self {
155        self.http_only = http_only;
156        self
157    }
158
159    /// Set the `Secure` flag on the CSRF cookie (default: `false`).
160    ///
161    /// Enable in production to prevent the cookie from being sent over
162    /// plain HTTP.
163    pub fn secure(mut self, secure: bool) -> Self {
164        self.secure = secure;
165        self
166    }
167
168    fn generate_token(&self) -> String {
169        let mut bytes = [0u8; 32];
170        OsRng.fill_bytes(&mut bytes);
171        bytes.iter().map(|b| format!("{:02x}", b)).collect()
172    }
173
174    fn get_cookie_token(&self, req: &Request) -> Option<String> {
175        get_cookie(req, &self.cookie_name)
176    }
177
178    fn get_submitted_token(&self, req: &Request) -> Option<String> {
179        for h in &req.headers {
180            if h.name.eq_ignore_ascii_case(&self.header_name) {
181                let val = h.value.trim().to_string();
182                if !val.is_empty() {
183                    return Some(val);
184                }
185            }
186        }
187        let is_form = req.headers.iter().any(|h| {
188            h.name.eq_ignore_ascii_case("content-type")
189                && h.value
190                    .to_lowercase()
191                    .contains("application/x-www-form-urlencoded")
192        });
193        if is_form {
194            return get_form_field(&req.body, &self.field_name);
195        }
196        None
197    }
198
199    fn cookie_header_value(&self, token: &str) -> String {
200        let mut s = format!(
201            "{}={}; Path=/; SameSite={}",
202            self.cookie_name, token, self.same_site
203        );
204        if self.http_only {
205            s.push_str("; HttpOnly");
206        }
207        if self.secure {
208            s.push_str("; Secure");
209        }
210        s
211    }
212}
213
214impl Middleware for CsrfLayer {
215    fn handle(
216        &self,
217        request: &Request,
218        connection: &ConnectionInfo,
219        next: &dyn Application,
220    ) -> Result<Response, String> {
221        if is_safe_method(&request.method) {
222            let token = self
223                .get_cookie_token(request)
224                .unwrap_or_else(|| self.generate_token());
225
226            // Inject so CsrfToken::from_request can retrieve it inside the handler.
227            let mut req = request.clone();
228            req.headers.push(Header {
229                name: INJECTED_HEADER.to_string().into(),
230                value: token.clone().into(),
231            });
232
233            let mut response = next.execute(&req, connection)?;
234            response.headers.push(Header {
235                name: "Set-Cookie".into(),
236                value: self.cookie_header_value(&token).into(),
237            });
238            Ok(response)
239        } else {
240            let cookie_token = match self.get_cookie_token(request) {
241                Some(t) => t,
242                None => return Ok(AppError::Forbidden.into_response()),
243            };
244            let submitted_token = match self.get_submitted_token(request) {
245                Some(t) => t,
246                None => return Ok(AppError::Forbidden.into_response()),
247            };
248            if !ct_eq(cookie_token.as_bytes(), submitted_token.as_bytes()) {
249                return Ok(AppError::Forbidden.into_response());
250            }
251            next.execute(request, connection)
252        }
253    }
254}
255
256/// The CSRF token for the current request.
257///
258/// Obtain via [`CsrfToken::from_request`] inside a `GET` handler (after
259/// [`CsrfLayer`] has run) and embed the value in your HTML form.
260///
261/// ```rust,no_run
262/// # extern crate rws_server as rust_web_server;
263/// use rust_web_server::csrf::CsrfToken;
264/// use rust_web_server::request::Request;
265///
266/// fn page(req: &Request) -> String {
267///     let token = CsrfToken::from_request(req)
268///         .map(|t| t.value().to_string())
269///         .unwrap_or_default();
270///     format!(r#"<input type="hidden" name="_csrf" value="{token}">"#)
271/// }
272/// ```
273pub struct CsrfToken(String);
274
275impl CsrfToken {
276    /// Return the token string.
277    pub fn value(&self) -> &str {
278        &self.0
279    }
280
281    /// Extract the current CSRF token from a request.
282    ///
283    /// Returns `Some` only when [`CsrfLayer`] is in the middleware stack and
284    /// the request was already processed by it. Returns `None` otherwise.
285    pub fn from_request(req: &Request) -> Option<Self> {
286        for h in &req.headers {
287            if h.name.eq_ignore_ascii_case(INJECTED_HEADER) {
288                return Some(CsrfToken(h.value.clone().into_owned()));
289            }
290        }
291        None
292    }
293}
294
295impl std::fmt::Display for CsrfToken {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        write!(f, "{}", self.0)
298    }
299}
300
301// ── Private helpers ──────────────────────────────────────────────────────────
302
303fn is_safe_method(method: &str) -> bool {
304    matches!(method, "GET" | "HEAD" | "OPTIONS" | "TRACE")
305}
306
307fn get_cookie(req: &Request, name: &str) -> Option<String> {
308    for h in &req.headers {
309        if h.name.eq_ignore_ascii_case("cookie") {
310            for part in h.value.split(';') {
311                let part = part.trim();
312                if let Some(pos) = part.find('=') {
313                    let k = part[..pos].trim();
314                    if k.eq_ignore_ascii_case(name) {
315                        return Some(part[pos + 1..].trim().to_string());
316                    }
317                }
318            }
319        }
320    }
321    None
322}
323
324fn get_form_field(body: &[u8], field: &str) -> Option<String> {
325    let s = std::str::from_utf8(body).ok()?;
326    for pair in s.split('&') {
327        let mut parts = pair.splitn(2, '=');
328        let k = parts.next()?.trim();
329        if k == field {
330            return Some(parts.next().unwrap_or("").to_string());
331        }
332    }
333    None
334}
335
336/// Constant-time byte-slice equality to prevent timing attacks.
337fn ct_eq(a: &[u8], b: &[u8]) -> bool {
338    if a.len() != b.len() {
339        return false;
340    }
341    let mut diff = 0u8;
342    for (x, y) in a.iter().zip(b.iter()) {
343        diff |= x ^ y;
344    }
345    diff == 0
346}