| 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 | |
| 11 | use hmac::{Hmac, Mac}; |
| 12 | use rand::RngCore; |
| 13 | use sha2::Sha256; |
| 14 | use subtle::ConstantTimeEq; |
| 15 | |
| 16 | pub const COOKIE_NAME: &str = "dogfood_csrf"; |
| 17 | pub const HEADER_NAME: &str = "x-csrf-token"; |
| 18 | pub const FORM_FIELD: &str = "_csrf"; |
| 19 | |
| 20 | type HmacSha256 = Hmac<Sha256>; |
| 21 | |
| 22 | /// Mint a signed CSRF token. |
| 23 | pub 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 | |
| 31 | fn 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. |
| 38 | pub 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. |
| 55 | pub 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)] |
| 67 | mod 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