rustango 0.60.7

Batteries-included web framework for Rust: ORM + migrations + auto-admin + multi-tenancy + audit log + auth (sessions, JWT, OAuth2/OIDC, HMAC) + APIs (ViewSet, OpenAPI auto-derive, JSON:API) + jobs (in-mem + Postgres) + email + media (S3 / R2 / B2 / MinIO + presigned uploads + collections + tags) + production middleware (CSRF, CSP, rate-limiting, compression, idempotency, etc.).
Documentation
//! Request-scoped CSRF token for admin templates.
//!
//! Admin CSRF protection has two halves, and both are needed. The
//! `CsrfLayer` rejects an unsafe request without a valid token; this
//! module puts a token in every form. The layer alone turns every
//! admin mutation into a 403, and tokens alone protect nothing.
//!
//! It mints or reuses one token per request, parks it in a task-local
//! so `chrome_context` can pass it to every template, and attaches the
//! matching `Set-Cookie` itself. The layer must not add that cookie
//! later: by then the handler has rendered a token and the two would
//! disagree. `CsrfLayer` stands down when the response already sets
//! the cookie, which is what makes this safe.

use axum::body::Body;
use axum::extract::Request;
use axum::http::header::SET_COOKIE;
use axum::http::HeaderValue;
use axum::middleware::Next;
use axum::response::Response;

use super::session::CURRENT_CSRF_TOKEN;

/// Install the request's CSRF token as a task-local, then attach the
/// cookie that pairs with it.
///
/// Mounted inside `csrf::layer()` and outside the session gate, so it
/// runs for every admin page, read pages included. A GET must seed the cookie, or the first POST from
/// a fresh browser has nothing to match against.
pub(crate) async fn csrf_context(request: Request<Body>, next: Next) -> Response {
    // Nested (tenant admin around the bare admin): the outer token wins,
    // or the page would render one token and set another cookie (#2131).
    if super::session::current_csrf_token().is_some() {
        return next.run(request).await;
    }
    // The outer layer's cookie name and `Secure` flag, or it checks a cookie never set (#2160).
    let (token, set_cookie) =
        crate::forms::csrf::ensure_token_under_layer(request.headers(), request.extensions());

    let mut response = CURRENT_CSRF_TOKEN.scope(token, next.run(request)).await;

    // Only when `ensure_token` minted one. It returns `None` if the
    // request already carried the cookie, and re-sending it would be
    // noise on every admin response.
    // Never on a publicly cacheable response (a brand asset): a shared
    // cache would hand the same token to everyone (#1714 review).
    let cacheable = crate::forms::csrf::is_publicly_cacheable(response.headers());
    if let Some(cookie) = set_cookie.filter(|_| !cacheable) {
        if let Ok(v) = HeaderValue::from_str(&cookie) {
            response.headers_mut().append(SET_COOKIE, v);
        }
    }
    response
}

#[cfg(test)]
mod tests {
    use axum::routing::get;
    use axum::Router;
    use tower::ServiceExt as _;

    use crate::forms::csrf::{with_config, CsrfConfig};

    /// #2160 — the page's token rides in the outer layer's cookie, and
    /// no second cookie is set.
    #[tokio::test]
    async fn the_token_cookie_follows_the_outer_layer() {
        let app = Router::new()
            .route(
                "/",
                get(|| async { super::super::session::current_csrf_token().unwrap_or_default() }),
            )
            .layer(axum::middleware::from_fn(super::csrf_context))
            .layer(with_config(CsrfConfig {
                cookie_name: "outer_csrf".into(),
                ..CsrfConfig::default()
            }));
        let resp = app
            .oneshot(
                axum::http::Request::builder()
                    .uri("/")
                    .body(super::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        let cookies: Vec<String> = resp
            .headers()
            .get_all(super::SET_COOKIE)
            .iter()
            .map(|c| c.to_str().unwrap().to_owned())
            .collect();
        let token = axum::body::to_bytes(resp.into_body(), 1 << 20)
            .await
            .unwrap();
        let token = String::from_utf8_lossy(&token);
        assert_eq!(cookies.len(), 1, "{cookies:?}");
        assert!(
            cookies[0].starts_with(&format!("outer_csrf={token};")),
            "{cookies:?}"
        );
        assert!(cookies[0].contains("; Secure"), "{cookies:?}");
    }
}