rullst-core 12.2.0

Core runtime, routing engine, and telemetry for the Rullst framework suite.
Documentation
//! CSRF protection middleware and Double Submit Cookie validation.

use axum::{
    extract::Request,
    http::{StatusCode, header},
    middleware::Next,
    response::{IntoResponse, Response},
};
use rand::distr::{Alphanumeric, SampleString};
use subtle::ConstantTimeEq;

/// Generates a cryptographically secure 32-character random alphanumeric string.
pub fn generate_csrf_token() -> String {
    Alphanumeric.sample_string(&mut rand::rng(), 32)
}

/// Request-scoped CSRF token made available to handlers rendering forms.
///
/// On the first `GET` or `HEAD` request this is the same token that the
/// middleware writes to the response cookie. Exposing it through request extensions avoids
/// rendering an empty hidden field before the browser has received that cookie.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CsrfToken(String);

impl CsrfToken {
    /// Returns the token that must be echoed in an `X-CSRF-Token` header or
    /// `_token` form field.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Private request marker preventing nested framework/application composition
/// from applying the double-submit protocol twice.
#[derive(Clone, Copy, Debug)]
struct CsrfMiddlewareApplied;

#[derive(serde::Deserialize)]
struct CsrfForm {
    _token: Option<String>,
}

/// Helper to extract the token from form-encoded body bytes.
pub(crate) fn extract_token_from_body(bytes: &[u8]) -> Option<String> {
    serde_urlencoded::from_bytes::<CsrfForm>(bytes)
        .ok()
        .and_then(|form| form._token)
}

/// Middleware that enforces CSRF protection using the Double Submit Cookie pattern.
/// GET and HEAD requests receive the request-scoped [`CsrfToken`] and generate a
/// CSRF cookie if missing; HEAD mirrors GET's headers (RFC 9110), so a `get`
/// route extracting `Extension<CsrfToken>` also answers HEAD. `OPTIONS` and
/// `TRACE` pass through, while state-changing requests must match the
/// `rullst_csrf` cookie token with either the `X-CSRF-Token` header or form
/// `_token` field.
///
/// Applying this middleware more than once to the same request is idempotent. This
/// matters when an application router adds the explicit development layer and
/// [`crate::Server`] later composes the production security baseline around it.
pub async fn csrf_middleware(mut req: Request, next: Next) -> Response {
    if let Some(policy) = req
        .extensions()
        .get::<super::MachineEndpointPolicy>()
        .cloned()
        && policy.matches(&req)
    {
        return match policy.authenticate(req).await {
            Ok(request) => next.run(request).await,
            Err(status) => (status, "Machine request rejected").into_response(),
        };
    }
    if req.extensions().get::<CsrfMiddlewareApplied>().is_some() {
        return next.run(req).await;
    }

    if is_signed_webhook_exemption(&req) {
        return next.run(req).await;
    }

    req.extensions_mut().insert(CsrfMiddlewareApplied);

    let method = req.method();

    // Axum serves HEAD through the GET handler, so HEAD gets the same token
    // and cookie as GET.
    if method == axum::http::Method::GET || method == axum::http::Method::HEAD {
        handle_csrf_get(req, next).await
    } else if method == axum::http::Method::OPTIONS || method == axum::http::Method::TRACE {
        next.run(req).await
    } else {
        handle_csrf_state_modifying(req, next).await
    }
}

fn is_signed_webhook_exemption(req: &Request) -> bool {
    if req.method() != axum::http::Method::POST {
        return false;
    }

    let config = req
        .extensions()
        .get::<crate::config::SecurityConfig>()
        .unwrap_or(&crate::config::RullstConfig::global().security);
    config
        .csrf_signed_webhook_paths
        .iter()
        .any(|path| path == req.uri().path())
}

pub(crate) fn is_csrf_exempt_path(path: &str) -> bool {
    path == "/robots.txt"
        || path == "/sitemap.xml"
        || path == "/favicon.ico"
        || path.starts_with("/static/")
        || path.ends_with(".txt")
        || path.ends_with(".xml")
        || path.ends_with(".ico")
        || path.ends_with(".json")
        || path.ends_with(".css")
        || path.ends_with(".js")
        || path.ends_with(".png")
        || path.ends_with(".jpg")
        || path.ends_with(".jpeg")
        || path.ends_with(".svg")
        || path.ends_with(".webp")
        || path.ends_with(".wasm")
}

async fn handle_csrf_get(mut req: Request, next: Next) -> Response {
    if is_csrf_exempt_path(req.uri().path()) {
        return next.run(req).await;
    }

    let cookie_token = csrf_token_from_cookies(req.headers());

    if let Some(token) = cookie_token {
        req.extensions_mut().insert(CsrfToken(token));
        return next.run(req).await;
    }

    let token = generate_csrf_token();
    req.extensions_mut().insert(CsrfToken(token.clone()));
    {
        let same_site = req
            .extensions()
            .get::<crate::config::SecurityConfig>()
            .map(|cfg| cfg.csrf_same_site.clone())
            .unwrap_or_else(|| "Lax".to_string());
        let secure_cookie = req
            .extensions()
            .get::<crate::config::Environment>()
            .copied()
            .map(crate::config::Environment::requires_secure_defaults)
            .unwrap_or_else(|| {
                crate::config::Environment::detect(None)
                    .map(crate::config::Environment::requires_secure_defaults)
                    .unwrap_or(true)
            });

        let mut response = next.run(req).await;

        let secure_attr = if secure_cookie { "; Secure" } else { "" };
        if let Ok(cookie_val) = header::HeaderValue::from_str(&format!(
            "rullst_csrf={}; Path=/; SameSite={}{}",
            token, same_site, secure_attr
        )) {
            response
                .headers_mut()
                .append(header::SET_COOKIE, cookie_val);
        }
        response
    }
}

/// Returns the single valid `rullst_csrf` cookie across every `Cookie` header.
///
/// Pairs are split on the raw header bytes: browsers send non-ASCII
/// `document.cookie` values as UTF-8, and such an unrelated cookie must not
/// hide the CSRF cookie. A duplicate or malformed CSRF cookie still fails.
fn csrf_token_from_cookies(headers: &header::HeaderMap) -> Option<String> {
    let mut found = None;
    for value in headers.get_all(header::COOKIE) {
        for cookie in value.as_bytes().split(|byte| *byte == b';') {
            let Some(token) = cookie.trim_ascii().strip_prefix(b"rullst_csrf=") else {
                continue;
            };
            if found.is_some()
                || !(1..=128).contains(&token.len())
                || !token
                    .iter()
                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
            {
                return None;
            }
            let Ok(token) = std::str::from_utf8(token) else {
                return None;
            };
            found = Some(token.to_owned());
        }
    }
    found
}

#[cfg_attr(mutants, mutants::skip)]
async fn handle_csrf_state_modifying(mut req: Request, next: Next) -> Response {
    let csrf_cookie = csrf_token_from_cookies(req.headers());

    let Some(cookie_token) = csrf_cookie else {
        return (StatusCode::FORBIDDEN, "CSRF token cookie missing").into_response();
    };

    // Check header first (common for AJAX/HTMX)
    let mut header_values = req.headers().get_all("X-CSRF-Token").iter();
    let header_token = header_values.next();
    if header_values.next().is_some() {
        return (StatusCode::FORBIDDEN, "Ambiguous CSRF token").into_response();
    }

    if let Some(token) = header_token {
        if token.as_bytes().len() == cookie_token.len()
            && token.as_bytes().ct_eq(cookie_token.as_bytes()).into()
        {
            req.extensions_mut().insert(CsrfToken(cookie_token.clone()));
            return next.run(req).await;
        }
        return (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response();
    }

    // If not in header, check if it's a form-urlencoded request before buffering the body
    let content_type = req
        .headers()
        .get(header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    if content_type.split(';').next().is_some_and(|media_type| {
        media_type
            .trim()
            .eq_ignore_ascii_case("application/x-www-form-urlencoded")
    }) {
        let (mut parts, body) = req.into_parts();

        // Read request body (limited to 1MB to prevent memory exhaustion)
        let bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
            Ok(b) => b,
            Err(_) => {
                return (StatusCode::BAD_REQUEST, "Failed to read request body").into_response();
            }
        };

        let body_token = extract_token_from_body(&bytes);

        if let Some(token) = body_token {
            if token.len() == cookie_token.len()
                && token.as_bytes().ct_eq(cookie_token.as_bytes()).into()
            {
                parts.extensions.insert(CsrfToken(cookie_token));
                // Reconstruct the request so it can be parsed by subsequent handlers.
                let reconstructed_req = Request::from_parts(parts, axum::body::Body::from(bytes));
                return next.run(reconstructed_req).await;
            }
        }
    }

    (StatusCode::FORBIDDEN, "Invalid or missing CSRF token").into_response()
}

#[cfg(test)]
#[path = "csrf_tests.rs"]
mod tests;