| 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