Skip to main content

actix_admin/
csrf.rs

1//! CSRF protection helpers.
2//!
3//! actix-admin protects every state-changing route (POST/PUT/PATCH/DELETE)
4//! with a per-session CSRF token stored in the `actix-session` cookie.
5//!
6//! * [`csrf_token_for`] returns the current token, creating one the first
7//!   time it is called for a session.
8//! * [`verify_csrf`] extracts the token from either the `X-CSRF-Token`
9//!   header (used by HTMX) or the `_csrf` query parameter (used by classic
10//!   form posts and multipart uploads, where the form body is streamed
11//!   lazily by `actix-multipart` and cannot be peeked ahead-of-time).
12//!
13//! The check can be globally disabled via
14//! [`crate::ActixAdminConfiguration::enable_csrf`] \u2014 in that case
15//! [`verify_csrf`] is a no-op.
16//!
17//! Setup: install a session middleware (e.g. `actix-session::CookieSession`)
18//! **before** the admin scope, exactly like you already need to for auth.
19//!
20//! HTMX wiring: `base.html` sets an `htmx:configRequest` listener that
21//! attaches the token as `X-CSRF-Token` on every HTMX call. Non-HTMX forms
22//! also include a hidden `_csrf` input, and delete/action URLs carry the
23//! token as a `_csrf=` query parameter.
24use actix_session::Session;
25use actix_web::HttpRequest;
26
27use crate::{ActixAdmin, ActixAdminError, ActixAdminErrorType};
28
29/// Session storage key. Public so applications can inspect/clear the token.
30pub const CSRF_SESSION_KEY: &str = "_actix_admin_csrf";
31/// Header name checked on every state-changing request.
32pub const CSRF_HEADER: &str = "X-CSRF-Token";
33/// Fallback query-string parameter, used when the header isn't available
34/// (classic multipart form submissions handled by actix-multipart).
35pub const CSRF_QUERY_PARAM: &str = "_csrf";
36
37/// Marker error type returned by [`verify_csrf`]. Currently equivalent to
38/// [`ActixAdminError`] with `ty = ActixAdminErrorType::CsrfError` \u2014 kept
39/// as a distinct alias in case a future release wants richer diagnostics.
40pub type CsrfError = ActixAdminError;
41
42/// Return the CSRF token for `session`, generating a fresh one if there is
43/// none. Safe to call from any handler; the value is stable for the lifetime
44/// of the session.
45pub fn csrf_token_for(session: &Session) -> Result<String, ActixAdminError> {
46    if let Some(existing) = session.get::<String>(CSRF_SESSION_KEY).unwrap_or(None) {
47        return Ok(existing);
48    }
49    let token = generate_token();
50    session
51        .insert(CSRF_SESSION_KEY, &token)
52        .map_err(|e| ActixAdminError::new(ActixAdminErrorType::InternalError, e.to_string()))?;
53    Ok(token)
54}
55
56/// Assert that `req` carries a valid CSRF token for `session`. Returns
57/// `Ok(())` when CSRF protection is disabled globally.
58///
59/// The token can be provided either via the `X-CSRF-Token` header (HTMX)
60/// or the `_csrf` query-string parameter (classic forms & multipart).
61pub fn verify_csrf(
62    actix_admin: &ActixAdmin,
63    session: &Session,
64    req: &HttpRequest,
65) -> Result<(), ActixAdminError> {
66    if !actix_admin.configuration.enable_csrf {
67        return Ok(());
68    }
69
70    let expected = session
71        .get::<String>(CSRF_SESSION_KEY)
72        .unwrap_or(None)
73        .ok_or_else(|| {
74            ActixAdminError::new(
75                ActixAdminErrorType::CsrfError,
76                "no CSRF token in session; reload the page and try again",
77            )
78        })?;
79
80    let from_header = req
81        .headers()
82        .get(CSRF_HEADER)
83        .and_then(|v| v.to_str().ok())
84        .map(str::to_owned);
85
86    let from_query = if from_header.is_none() {
87        form_urlencoded::parse(req.query_string().as_bytes())
88            .find(|(k, _)| k == CSRF_QUERY_PARAM)
89            .map(|(_, v)| v.into_owned())
90    } else {
91        None
92    };
93
94    let received = from_header.or(from_query).ok_or_else(|| {
95        ActixAdminError::new(
96            ActixAdminErrorType::CsrfError,
97            "missing CSRF token (expected `X-CSRF-Token` header or `_csrf` query param)",
98        )
99    })?;
100
101    if constant_time_eq(received.as_bytes(), expected.as_bytes()) {
102        Ok(())
103    } else {
104        Err(ActixAdminError::new(
105            ActixAdminErrorType::CsrfError,
106            "CSRF token mismatch",
107        ))
108    }
109}
110
111/// Constant-time byte comparison to avoid timing side channels.
112fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
113    if a.len() != b.len() {
114        return false;
115    }
116    let mut diff: u8 = 0;
117    for (x, y) in a.iter().zip(b.iter()) {
118        diff |= x ^ y;
119    }
120    diff == 0
121}
122
123/// Generate a 32-byte URL-safe base64 token.
124///
125/// Prefers the OS CSPRNG (`getrandom`); if that is unavailable for any reason
126/// (some sandboxed / no-syscall targets) we fall back to a `splitmix64`
127/// mixer seeded from the system clock, a monotonically increasing counter
128/// and the address of a stack variable. That fallback is *not* a CSPRNG and
129/// only exists so the crate keeps building on unusual targets; the token is
130/// scoped to a signed/encrypted session cookie which is the real trust
131/// boundary.
132fn generate_token() -> String {
133    let mut bytes = [0u8; 32];
134    if getrandom::getrandom(&mut bytes).is_err() {
135        fill_fallback(&mut bytes);
136    }
137    base64_url(&bytes)
138}
139
140fn fill_fallback(bytes: &mut [u8; 32]) {
141    use std::sync::atomic::{AtomicU64, Ordering};
142    use std::time::{SystemTime, UNIX_EPOCH};
143    static COUNTER: AtomicU64 = AtomicU64::new(0);
144
145    let now = SystemTime::now()
146        .duration_since(UNIX_EPOCH)
147        .map(|d| d.as_nanos() as u64)
148        .unwrap_or(0);
149    let ctr = COUNTER.fetch_add(1, Ordering::Relaxed);
150    let stack = &now as *const _ as usize as u64;
151
152    let mut state: u64 = now.wrapping_mul(0x9E3779B97F4A7C15) ^ ctr ^ stack;
153    for chunk in bytes.chunks_mut(8) {
154        state = state.wrapping_add(0x9E3779B97F4A7C15);
155        let mut z = state;
156        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
157        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
158        z ^= z >> 31;
159        chunk.copy_from_slice(&z.to_le_bytes()[..chunk.len()]);
160    }
161}
162
163fn base64_url(data: &[u8]) -> String {
164    const CHARSET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
165    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
166    let mut i = 0;
167    while i < data.len() {
168        let b0 = data[i] as u32;
169        let b1 = data.get(i + 1).copied().unwrap_or(0) as u32;
170        let b2 = data.get(i + 2).copied().unwrap_or(0) as u32;
171        let n = (b0 << 16) | (b1 << 8) | b2;
172        out.push(CHARSET[((n >> 18) & 63) as usize] as char);
173        out.push(CHARSET[((n >> 12) & 63) as usize] as char);
174        if i + 1 < data.len() {
175            out.push(CHARSET[((n >> 6) & 63) as usize] as char);
176        }
177        if i + 2 < data.len() {
178            out.push(CHARSET[(n & 63) as usize] as char);
179        }
180        i += 3;
181    }
182    out
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn tokens_are_reasonably_unique() {
191        let a = generate_token();
192        let b = generate_token();
193        assert_ne!(a, b);
194        assert!(a.len() >= 40);
195    }
196
197    #[test]
198    fn constant_time_eq_basic() {
199        assert!(constant_time_eq(b"abc", b"abc"));
200        assert!(!constant_time_eq(b"abc", b"abd"));
201        assert!(!constant_time_eq(b"abc", b"abcd"));
202    }
203}