Skip to main content

adminx_core/
csrf.rs

1// adminx-core/src/csrf.rs
2//
3// CSRF protection for the HTML form posts (login, MFA setup/verify), using the
4// double-submit cookie pattern: a random token is stored in its own cookie and
5// mirrored in a hidden form field. A forged cross-site POST can neither read the
6// victim's cookie (cross-origin) nor have it sent (`SameSite=Strict`), so the
7// two can only match on a request that genuinely originated from our own page.
8//
9// This is deliberately stateless, like the auth JWT: nothing to store or expire
10// server-side, and identical behaviour on Actix and Axum.
11//
12// Note the auth cookie is already `SameSite=Strict`, which alone blocks most
13// forged posts to *authenticated* endpoints. The case that needs this module is
14// `POST /login`, which carries no prior cookie and so gets no SameSite
15// protection: without a token an attacker can force a victim's browser to log
16// into the *attacker's* account. The rest is defence in depth (legacy browsers,
17// and same-site-but-not-same-origin attackers such as a hostile subdomain).
18
19use crate::request::ReqCtx;
20use rand::Rng;
21
22/// Cookie name holding the CSRF token.
23pub const COOKIE_NAME: &str = "adminx_csrf";
24/// Hidden form field mirroring the cookie.
25pub const FIELD_NAME: &str = "_csrf";
26
27/// A fresh 256-bit token, hex-encoded.
28fn generate() -> String {
29    let bytes: [u8; 32] = rand::thread_rng().gen();
30    bytes.iter().map(|b| format!("{b:02x}")).collect()
31}
32
33/// `Set-Cookie` value for `token`. A session cookie (no `Max-Age`): it only has
34/// to outlive the form it guards, and a fresh one is minted whenever a form page
35/// is rendered without one.
36fn set_cookie_value(token: &str) -> String {
37    let mut v = format!("{COOKIE_NAME}={token}; HttpOnly; SameSite=Strict; Path=/");
38    if crate::auth::secure_cookie() {
39        v.push_str("; Secure");
40    }
41    v
42}
43
44/// Token to embed in a form, plus a `Set-Cookie` value when a new one was
45/// minted. An existing cookie is reused rather than replaced, so opening the
46/// same form in two tabs doesn't invalidate the first one's token.
47pub fn ensure(ctx: &ReqCtx) -> (String, Option<String>) {
48    match ctx.csrf.as_deref().filter(|t| !t.is_empty()) {
49        Some(existing) => (existing.to_string(), None),
50        None => {
51            let token = generate();
52            let cookie = set_cookie_value(&token);
53            (token, Some(cookie))
54        }
55    }
56}
57
58/// Constant-time string equality. Length is not secret (tokens are fixed-width).
59fn ct_eq(a: &str, b: &str) -> bool {
60    let (a, b) = (a.as_bytes(), b.as_bytes());
61    if a.len() != b.len() {
62        return false;
63    }
64    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
65}
66
67/// True when the submitted field matches the cookie. Both must be present and
68/// non-empty, so a request that carries neither is rejected rather than passed.
69pub fn verify(ctx: &ReqCtx, submitted: Option<&str>) -> bool {
70    let cookie = match ctx.csrf.as_deref().filter(|t| !t.is_empty()) {
71        Some(c) => c,
72        None => return false,
73    };
74    match submitted.filter(|s| !s.is_empty()) {
75        Some(s) => ct_eq(cookie, s),
76        None => false,
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn ctx_with(token: Option<&str>) -> ReqCtx {
85        let ctx = ReqCtx::new();
86        match token {
87            Some(t) => ctx.with_csrf(t),
88            None => ctx,
89        }
90    }
91
92    #[test]
93    fn tokens_are_unique_and_hex() {
94        let (a, b) = (generate(), generate());
95        assert_ne!(a, b);
96        assert_eq!(a.len(), 64);
97        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
98    }
99
100    #[test]
101    fn ensure_mints_when_absent_and_reuses_when_present() {
102        let (token, cookie) = ensure(&ctx_with(None));
103        assert!(cookie.expect("should mint a cookie").contains(&token));
104
105        let (token, cookie) = ensure(&ctx_with(Some("existing")));
106        assert_eq!(token, "existing");
107        assert!(cookie.is_none(), "an existing token must be reused as-is");
108    }
109
110    #[test]
111    fn verify_requires_a_matching_pair() {
112        assert!(verify(&ctx_with(Some("abc")), Some("abc")));
113        // Forged post: attacker guesses a value but the cookie isn't sent.
114        assert!(!verify(&ctx_with(None), Some("abc")));
115        // Cookie present but no field (a bare cross-site form post).
116        assert!(!verify(&ctx_with(Some("abc")), None));
117        assert!(!verify(&ctx_with(Some("abc")), Some("xyz")));
118        // Empty values must never satisfy the check.
119        assert!(!verify(&ctx_with(Some("")), Some("")));
120        assert!(!verify(&ctx_with(Some("abc")), Some("")));
121    }
122}