Skip to main content

tablo_core/
csrf.rs

1//! CSRF protection via double-submit cookie.
2//!
3//! Verifies state-changing POSTs with a double-submit `__Host-` cookie compared in constant time.
4
5use subtle::ConstantTimeEq;
6use topcoat::{
7    context::{Cx, try_request_context},
8    cookie::{Cookie, CookieJarCell, Cookies, cookies},
9};
10
11/// Names the `__Host-`-prefixed CSRF cookie.
12pub const COOKIE_NAME: &str = "__Host-tablo_csrf";
13pub const FIELD_NAME: &str = "csrf_token";
14
15/// Ensures a request token before headers send, returning `""` without a cookie layer.
16pub fn ensure_token(cx: &Cx) -> String {
17    if try_request_context::<CookieJarCell>(cx).is_none() {
18        return String::new();
19    }
20    let jar = cookies(cx);
21    if let Some(cookie) = jar.get(COOKIE_NAME) {
22        let value = cookie.value().to_string();
23        if is_valid_token(&value) {
24            return value;
25        }
26    }
27    let token = uuid::Uuid::new_v4().to_string();
28    let cookie = Cookie::build((COOKIE_NAME, token.clone()))
29        .path("/")
30        .http_only(true)
31        .secure(true)
32        .same_site(topcoat::cookie::SameSite::Lax)
33        .build();
34    jar.add(cookie);
35    token
36}
37
38/// Reads the current token without setting one, safe inside streamed children.
39pub fn current_token(cx: &Cx) -> String {
40    if try_request_context::<CookieJarCell>(cx).is_none() {
41        return String::new();
42    }
43    cookies(cx)
44        .get(COOKIE_NAME)
45        .map(|c| c.value().to_string())
46        .filter(|v| is_valid_token(v))
47        .unwrap_or_default()
48}
49
50/// Renders the hidden field embedding the given token.
51pub fn field<'a>(cx: &'a Cx, token: &str) -> topcoat::view::BoxView<'a> {
52    use topcoat::view::ViewExt;
53
54    let token = token.to_string();
55    topcoat::view::view! { cx => <input type="hidden" name=(FIELD_NAME) value=(token)> }.boxed()
56}
57
58/// Verifies the submitted token matches the cookie with a constant-time compare, failing closed
59/// with 403.
60pub fn verify(
61    cx: &Cx,
62    values: &std::collections::HashMap<String, String>,
63) -> Result<(), topcoat::Error> {
64    let cookie_ok = try_request_context::<CookieJarCell>(cx)
65        .map(|_| cookies(cx).get(COOKIE_NAME).map(|c| c.value().to_string()))
66        .unwrap_or(None);
67    let Some(expected) = cookie_ok else {
68        return Err(topcoat::router::error::forbidden().into());
69    };
70    let Some(submitted) = values.get(FIELD_NAME) else {
71        return Err(topcoat::router::error::forbidden().into());
72    };
73    if !is_valid_token(&expected) || submitted.as_bytes().ct_ne(expected.as_bytes()).into() {
74        return Err(topcoat::router::error::forbidden().into());
75    }
76    Ok(())
77}
78
79fn is_valid_token(value: &str) -> bool {
80    value.parse::<uuid::Uuid>().is_ok()
81}
82
83#[cfg(test)]
84mod tests;