Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! CSRF protection: double-submit cookie (spec §7).
Matt W2//!
Matt W3//! "CSRF: double-submit cookie, validated on every non-GET. Add the token via
Matt W4//! `hx-headers` on the body element."
Matt W5//!
Matt W6//! The cookie value is `<random>.<hmac>` so the server can tell its own token
Matt W7//! from one an attacker planted via a cookie-injection bug on a sibling
Matt W8//! subdomain. Cookies do not respect origin boundaries the way headers do, so a
Matt W9//! plain unauthenticated random value is weaker than it looks.
Matt W10
Matt W11use hmac::{Hmac, Mac};
Matt W12use rand::RngCore;
Matt W13use sha2::Sha256;
Matt W14use subtle::ConstantTimeEq;
Matt W15
Matt W16pub const COOKIE_NAME: &str = "dogfood_csrf";
Matt W17pub const HEADER_NAME: &str = "x-csrf-token";
Matt W18pub const FORM_FIELD: &str = "_csrf";
Matt W19
Matt W20type HmacSha256 = Hmac<Sha256>;
Matt W21
Matt W22/// Mint a signed CSRF token.
Matt W23pub fn issue(secret: &[u8]) -> String {
Matt W24 let mut nonce = [0u8; 16];
Matt W25 rand::thread_rng().fill_bytes(&mut nonce);
Matt W26 let nonce_hex = hex::encode(nonce);
Matt W27 let tag = sign(secret, &nonce_hex);
Matt W28 format!("{nonce_hex}.{tag}")
Matt W29}
Matt W30
Matt W31fn sign(secret: &[u8], nonce: &str) -> String {
Matt W32 let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
Matt W33 mac.update(nonce.as_bytes());
Matt W34 hex::encode(mac.finalize().into_bytes())
Matt W35}
Matt W36
Matt W37/// Whether a token is well-formed and carries our signature.
Matt W38pub fn is_valid(secret: &[u8], token: &str) -> bool {
Matt W39 let Some((nonce, tag)) = token.split_once('.') else {
Matt W40 return false;
Matt W41 };
Matt W42 if nonce.len() != 32 || tag.len() != 64 {
Matt W43 return false;
Matt W44 }
Matt W45 let expected = sign(secret, nonce);
Matt W46 expected.as_bytes().ct_eq(tag.as_bytes()).into()
Matt W47}
Matt W48
Matt W49/// Validate a request: the cookie and the submitted value must both be present,
Matt W50/// both be authentic, and match each other.
Matt W51///
Matt W52/// Comparison is constant-time. The equality check matters as much as the
Matt W53/// signature: an attacker who can plant a cookie but cannot read it still must
Matt W54/// not be able to submit a matching header.
Matt W55pub fn verify(secret: &[u8], cookie_value: Option<&str>, submitted: Option<&str>) -> bool {
Matt W56 let (Some(cookie), Some(submitted)) = (cookie_value, submitted) else {
Matt W57 return false;
Matt W58 };
Matt W59 if !is_valid(secret, cookie) {
Matt W60 return false;
Matt W61 }
Matt W62 let eq: bool = cookie.as_bytes().ct_eq(submitted.as_bytes()).into();
Matt W63 eq
Matt W64}
Matt W65
Matt W66#[cfg(test)]
Matt W67mod tests {
Matt W68 use super::*;
Matt W69
Matt W70 const SECRET: &[u8] = b"test-secret-value-for-csrf-tokens";
Matt W71
Matt W72 #[test]
Matt W73 fn issued_tokens_validate() {
Matt W74 let t = issue(SECRET);
Matt W75 assert!(is_valid(SECRET, &t));
Matt W76 assert!(verify(SECRET, Some(&t), Some(&t)));
Matt W77 }
Matt W78
Matt W79 #[test]
Matt W80 fn tokens_are_unique_per_issue() {
Matt W81 assert_ne!(issue(SECRET), issue(SECRET));
Matt W82 }
Matt W83
Matt W84 #[test]
Matt W85 fn rejects_a_token_signed_with_another_secret() {
Matt W86 // The attack a plain random double-submit cookie does not stop: an
Matt W87 // attacker who can set cookies on the domain plants a value they know.
Matt W88 let planted = issue(b"attacker-secret");
Matt W89 assert!(!is_valid(SECRET, &planted));
Matt W90 assert!(!verify(SECRET, Some(&planted), Some(&planted)));
Matt W91 }
Matt W92
Matt W93 #[test]
Matt W94 fn rejects_mismatched_cookie_and_submission() {
Matt W95 let a = issue(SECRET);
Matt W96 let b = issue(SECRET);
Matt W97 assert!(
Matt W98 !verify(SECRET, Some(&a), Some(&b)),
Matt W99 "two individually-valid tokens must still have to match each other"
Matt W100 );
Matt W101 }
Matt W102
Matt W103 #[test]
Matt W104 fn rejects_missing_parts() {
Matt W105 let t = issue(SECRET);
Matt W106 assert!(!verify(SECRET, None, Some(&t)));
Matt W107 assert!(!verify(SECRET, Some(&t), None));
Matt W108 assert!(!verify(SECRET, None, None));
Matt W109 }
Matt W110
Matt W111 #[test]
Matt W112 fn rejects_malformed_tokens() {
Matt W113 for bad in [
Matt W114 "",
Matt W115 "nodot",
Matt W116 ".",
Matt W117 "short.short",
Matt W118 &format!("{}.{}", "a".repeat(32), "b".repeat(64)), // right shape, wrong tag
Matt W119 &format!("{}.{}", "a".repeat(31), "b".repeat(64)),
Matt W120 ] {
Matt W121 assert!(!is_valid(SECRET, bad), "must reject {bad:?}");
Matt W122 }
Matt W123 }
Matt W124
Matt W125 #[test]
Matt W126 fn a_tampered_nonce_invalidates_the_signature() {
Matt W127 let t = issue(SECRET);
Matt W128 let (nonce, tag) = t.split_once('.').unwrap();
Matt W129 let mut n: Vec<char> = nonce.chars().collect();
Matt W130 n[0] = if n[0] == 'a' { 'b' } else { 'a' };
Matt W131 let tampered = format!("{}.{}", n.into_iter().collect::<String>(), tag);
Matt W132 assert!(!is_valid(SECRET, &tampered));
Matt W133 }
Matt W134}

134 lines · Rust