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}