csrf_protection.rs 316 lignes · 12529 octets
//! Integration tests for the structural CSRF protection wiring (Lot 1).
//!
//! These tests reproduce the browser/machine router split performed by
//! `RustwardenBuilder::build` (cf. `crates/rustwarden-core/src/app.rs` and
//! `docs/plans/2026-06-04-csrf-protection-structurelle.md`):
//!
//! * `browser_router` carries the CSRF layer
//!   ([`csrf_protection_with_config`]) *and* a global `session_layer`. The
//!   session layer is applied last (outermost) so it runs **before** the CSRF
//!   check, matching production where `session_layer` is global and the CSRF
//!   layer is internal to the browser sub-router.
//! * `machine_router` carries the session layer but no CSRF layer.
//!
//! The database connection threaded into the CSRF state is a SeaORM mock: the
//! middleware ignores it functionally (`State((_db, cfg))`), so no live
//! database is required.

use axum::{
    Router,
    body::Body,
    http::{Method, StatusCode, header},
    response::IntoResponse,
    routing::{get, post},
};
use rustwarden_core::middleware::csrf::{
    CsrfProtectionConfig, DEFAULT_MAX_FORM_BODY_BYTES, csrf_protection_with_config,
    get_or_create_csrf_token,
};
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
use std::sync::Arc;
use tower::ServiceExt; // for `oneshot`
use tower_sessions::{MemoryStore, SessionManagerLayer, cookie::SameSite};

/// Build a mock SeaORM connection. The CSRF middleware never queries it.
fn mock_db() -> DatabaseConnection {
    MockDatabase::new(DatabaseBackend::Postgres).into_connection()
}

/// Session layer mirroring the production cookie config closely enough for the
/// token round-trip (non-secure so the cookie is accepted over plain HTTP in
/// tests).
fn session_layer() -> SessionManagerLayer<MemoryStore> {
    SessionManagerLayer::new(MemoryStore::default())
        .with_secure(false)
        .with_same_site(SameSite::Lax)
}

/// Echo handler with **no** CSRF code of its own — proves the protection is
/// structural (T10). Returns 200 with a fixed body when reached.
async fn echo_handler() -> impl IntoResponse {
    (StatusCode::OK, "echoed")
}

/// Form-extracting handler with **no** CSRF code of its own. Reads its
/// `Form<HashMap<..>>` *after* the middleware has buffered and reconstructed
/// the body, and echoes back the `field` value. Used by T4 to prove the body
/// is genuinely replayed to downstream extractors (piège n°5).
async fn form_echo_handler(
    axum::Form(form): axum::Form<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
    match form.get("field") {
        Some(v) => (StatusCode::OK, format!("field={v}")).into_response(),
        None => (StatusCode::UNPROCESSABLE_ENTITY, "missing field").into_response(),
    }
}

/// GET handler that primes the session CSRF token and returns it in the body,
/// so a test can capture both the session cookie and the token for replay.
async fn token_handler(session: tower_sessions::Session) -> impl IntoResponse {
    match get_or_create_csrf_token(&session).await {
        Ok(token) => (StatusCode::OK, token).into_response(),
        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response(),
    }
}

/// Assemble the test app reproducing the browser/machine split.
///
/// Browser routes: `/__test/token` (GET, primes token), `/__test/echo`
/// (POST, no CSRF code). Machine routes: `/__machine/echo` (POST).
fn test_app() -> Router {
    let csrf_cfg = Arc::new(CsrfProtectionConfig {
        accept_form_body: true,
        max_form_body_bytes: DEFAULT_MAX_FORM_BODY_BYTES,
    });

    let browser_router: Router<DatabaseConnection> = Router::new()
        .route("/__test/token", get(token_handler))
        .route("/__test/echo", post(echo_handler))
        .route("/__test/form", post(form_echo_handler))
        .layer(axum::middleware::from_fn_with_state(
            (mock_db(), csrf_cfg),
            csrf_protection_with_config,
        ));

    let machine_router: Router<DatabaseConnection> =
        Router::new().route("/__machine/echo", post(echo_handler));

    browser_router
        .merge(machine_router)
        .with_state(mock_db())
        .layer(session_layer())
}

/// Extract the `id` session cookie value (the `Set-Cookie` header set by
/// tower-sessions) so it can be replayed on a subsequent request.
fn extract_session_cookie(resp: &axum::response::Response) -> Option<String> {
    let raw = resp
        .headers()
        .get(header::SET_COOKIE)?
        .to_str()
        .ok()?
        .to_string();
    // Keep only the `name=value` pair (before the first `;`).
    Some(raw.split(';').next()?.to_string())
}

async fn body_string(resp: axum::response::Response) -> String {
    let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
        .await
        .expect("body read");
    String::from_utf8(bytes.to_vec()).expect("utf8 body")
}

/// Perform the GET that primes the token, returning `(session_cookie, token)`.
async fn prime_token(app: &Router) -> (String, String) {
    let req = axum::http::Request::builder()
        .method(Method::GET)
        .uri("/__test/token")
        .body(Body::empty())
        .expect("request");
    let resp = app.clone().oneshot(req).await.expect("oneshot");
    assert_eq!(
        resp.status(),
        StatusCode::OK,
        "token priming GET must succeed"
    );
    let cookie = extract_session_cookie(&resp).expect("session cookie set");
    let token = body_string(resp).await;
    assert_eq!(token.len(), 64, "token must be 64 hex chars");
    (cookie, token)
}

// ---------------------------------------------------------------------------
// T5 — Safe methods are never blocked.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn t5_get_browser_route_without_token_is_not_403() {
    let app = test_app();
    let req = axum::http::Request::builder()
        .method(Method::GET)
        .uri("/__test/token")
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_ne!(resp.status(), StatusCode::FORBIDDEN);
}

// ---------------------------------------------------------------------------
// T1 — POST on a browser route without a token → 403.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn t1_post_browser_route_without_token_is_403() {
    let app = test_app();
    let req = axum::http::Request::builder()
        .method(Method::POST)
        .uri("/__test/echo")
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

// ---------------------------------------------------------------------------
// T2 — POST with an invalid X-CSRF-Token → 403.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn t2_post_browser_route_with_invalid_token_is_403() {
    let app = test_app();
    // Prime a session so a *real* session token exists to mismatch against.
    let (cookie, _token) = prime_token(&app).await;
    let req = axum::http::Request::builder()
        .method(Method::POST)
        .uri("/__test/echo")
        .header(header::COOKIE, cookie)
        .header("X-CSRF-Token", "not-the-right-token")
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

// ---------------------------------------------------------------------------
// T6 — Bearer-authed POST without a CSRF token bypasses the check.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn t6_post_browser_route_with_bearer_is_not_403() {
    let app = test_app();
    let req = axum::http::Request::builder()
        .method(Method::POST)
        .uri("/__test/echo")
        .header(header::AUTHORIZATION, "Bearer some.jwt.token")
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_ne!(resp.status(), StatusCode::FORBIDDEN);
    assert_eq!(resp.status(), StatusCode::OK);
}

// ---------------------------------------------------------------------------
// T10 — Secure-by-default: a POST route with no CSRF code in its handler is
// still rejected without a token and accepted with a valid one.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn t10_echo_route_without_handler_csrf_code_is_protected() {
    let app = test_app();

    // Without a token → 403.
    let req = axum::http::Request::builder()
        .method(Method::POST)
        .uri("/__test/echo")
        .body(Body::empty())
        .unwrap();
    let resp = app.clone().oneshot(req).await.unwrap();
    assert_eq!(
        resp.status(),
        StatusCode::FORBIDDEN,
        "no token must be rejected"
    );

    // With a valid token (header channel) → 200.
    let (cookie, token) = prime_token(&app).await;
    let req = axum::http::Request::builder()
        .method(Method::POST)
        .uri("/__test/echo")
        .header(header::COOKIE, cookie)
        .header("X-CSRF-Token", token)
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK, "valid token must pass");
}

// ---------------------------------------------------------------------------
// T3 — Success via the X-CSRF-Token header (round-trip session + token).
// ---------------------------------------------------------------------------
#[tokio::test]
async fn t3_post_browser_route_with_valid_header_token_succeeds() {
    let app = test_app();
    let (cookie, token) = prime_token(&app).await;
    let req = axum::http::Request::builder()
        .method(Method::POST)
        .uri("/__test/echo")
        .header(header::COOKIE, cookie)
        .header("X-CSRF-Token", token)
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

// ---------------------------------------------------------------------------
// T4 — Success via the form body channel (`csrf_token=<valid>`), proving the
// buffered body is reconstructed for downstream handlers.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn t4_post_browser_route_with_valid_form_body_token_succeeds() {
    let app = test_app();
    let (cookie, token) = prime_token(&app).await;
    let body = format!("csrf_token={token}&field=value");
    let req = axum::http::Request::builder()
        .method(Method::POST)
        .uri("/__test/form")
        .header(header::COOKIE, cookie)
        .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
        .body(Body::from(body))
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    // Prove the body was reconstructed: the downstream `Form<T>` extractor
    // still sees `field=value` after the middleware buffered the body to read
    // the CSRF token (piège n°5).
    assert_eq!(body_string(resp).await, "field=value");
}

// ---------------------------------------------------------------------------
// T10a / T10b — Machine router has no CSRF layer: a POST is never 403 for a
// CSRF reason, with or without a token.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn t10a_machine_route_post_without_token_is_not_403() {
    let app = test_app();
    let req = axum::http::Request::builder()
        .method(Method::POST)
        .uri("/__machine/echo")
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_ne!(resp.status(), StatusCode::FORBIDDEN);
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn t10b_machine_route_post_with_session_cookie_is_not_403() {
    let app = test_app();
    // Even carrying a session cookie (browser-like) the machine route is open.
    let (cookie, _token) = prime_token(&app).await;
    let req = axum::http::Request::builder()
        .method(Method::POST)
        .uri("/__machine/echo")
        .header(header::COOKIE, cookie)
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_ne!(resp.status(), StatusCode::FORBIDDEN);
    assert_eq!(resp.status(), StatusCode::OK);
}