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); }
GitRust