Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! CSRF protection: double-submit cookie (spec §7).
2//!
3//! "CSRF: double-submit cookie, validated on every non-GET. Add the token via
4//! `hx-headers` on the body element."
5//!
6//! The cookie value is `<random>.<hmac>` so the server can tell its own token
7//! from one an attacker planted via a cookie-injection bug on a sibling
8//! subdomain. Cookies do not respect origin boundaries the way headers do, so a
9//! plain unauthenticated random value is weaker than it looks.
10
11use hmac::{Hmac, Mac};
12use rand::RngCore;
13use sha2::Sha256;
14use subtle::ConstantTimeEq;
15
16pub const COOKIE_NAME: &str = "dogfood_csrf";
17pub const HEADER_NAME: &str = "x-csrf-token";
18pub const FORM_FIELD: &str = "_csrf";
19
20type HmacSha256 = Hmac<Sha256>;
21
22/// Mint a signed CSRF token.
23pub fn issue(secret: &[u8]) -> String {
24 let mut nonce = [0u8; 16];
25 rand::thread_rng().fill_bytes(&mut nonce);
26 let nonce_hex = hex::encode(nonce);
27 let tag = sign(secret, &nonce_hex);
28 format!("{nonce_hex}.{tag}")
29}
30
31fn sign(secret: &[u8], nonce: &str) -> String {
32 let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
33 mac.update(nonce.as_bytes());
34 hex::encode(mac.finalize().into_bytes())
35}
36
37/// Whether a token is well-formed and carries our signature.
38pub fn is_valid(secret: &[u8], token: &str) -> bool {
39 let Some((nonce, tag)) = token.split_once('.') else {
40 return false;
41 };
42 if nonce.len() != 32 || tag.len() != 64 {
43 return false;
44 }
45 let expected = sign(secret, nonce);
46 expected.as_bytes().ct_eq(tag.as_bytes()).into()
47}
48
49/// Validate a request: the cookie and the submitted value must both be present,
50/// both be authentic, and match each other.
51///
52/// Comparison is constant-time. The equality check matters as much as the
53/// signature: an attacker who can plant a cookie but cannot read it still must
54/// not be able to submit a matching header.
55pub fn verify(secret: &[u8], cookie_value: Option<&str>, submitted: Option<&str>) -> bool {
56 let (Some(cookie), Some(submitted)) = (cookie_value, submitted) else {
57 return false;
58 };
59 if !is_valid(secret, cookie) {
60 return false;
61 }
62 let eq: bool = cookie.as_bytes().ct_eq(submitted.as_bytes()).into();
63 eq
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 const SECRET: &[u8] = b"test-secret-value-for-csrf-tokens";
71
72 #[test]
73 fn issued_tokens_validate() {
74 let t = issue(SECRET);
75 assert!(is_valid(SECRET, &t));
76 assert!(verify(SECRET, Some(&t), Some(&t)));
77 }
78
79 #[test]
80 fn tokens_are_unique_per_issue() {
81 assert_ne!(issue(SECRET), issue(SECRET));
82 }
83
84 #[test]
85 fn rejects_a_token_signed_with_another_secret() {
86 // The attack a plain random double-submit cookie does not stop: an
87 // attacker who can set cookies on the domain plants a value they know.
88 let planted = issue(b"attacker-secret");
89 assert!(!is_valid(SECRET, &planted));
90 assert!(!verify(SECRET, Some(&planted), Some(&planted)));
91 }
92
93 #[test]
94 fn rejects_mismatched_cookie_and_submission() {
95 let a = issue(SECRET);
96 let b = issue(SECRET);
97 assert!(
98 !verify(SECRET, Some(&a), Some(&b)),
99 "two individually-valid tokens must still have to match each other"
100 );
101 }
102
103 #[test]
104 fn rejects_missing_parts() {
105 let t = issue(SECRET);
106 assert!(!verify(SECRET, None, Some(&t)));
107 assert!(!verify(SECRET, Some(&t), None));
108 assert!(!verify(SECRET, None, None));
109 }
110
111 #[test]
112 fn rejects_malformed_tokens() {
113 for bad in [
114 "",
115 "nodot",
116 ".",
117 "short.short",
118 &format!("{}.{}", "a".repeat(32), "b".repeat(64)), // right shape, wrong tag
119 &format!("{}.{}", "a".repeat(31), "b".repeat(64)),
120 ] {
121 assert!(!is_valid(SECRET, bad), "must reject {bad:?}");
122 }
123 }
124
125 #[test]
126 fn a_tampered_nonce_invalidates_the_signature() {
127 let t = issue(SECRET);
128 let (nonce, tag) = t.split_once('.').unwrap();
129 let mut n: Vec<char> = nonce.chars().collect();
130 n[0] = if n[0] == 'a' { 'b' } else { 'a' };
131 let tampered = format!("{}.{}", n.into_iter().collect::<String>(), tag);
132 assert!(!is_valid(SECRET, &tampered));
133 }
134}

134 lines · Rust