csrf_seam.rs
286 lignes · 12598 octets
//! Inter-crate CSRF seam tests for the federation routers (Lot 2). //! //! These tests prove the *real* fediverse routers sit on the right side of the //! browser/machine split performed by `app/src/main.rs`: //! //! * `rustwarden_fediverse_ui::default_routes()` (login / consent / invite / //! admin SSR forms) is the **browser** family → it must ride the CSRF layer. //! A mutating `POST` without a token is rejected with **403** *before the //! handler runs* (the layer is outermost on the sub-router, so no live //! database is ever touched on the rejected path). //! * The fediverse inbox router (`POST /inbox`, authenticated by HTTP //! signatures) is the **machine** family → it carries **no** CSRF layer, so //! it is never rejected for a CSRF reason. //! //! The mount mirrors the Lot 1 wiring (`crates/rustwarden-core/tests/csrf_protection.rs`): //! the browser sub-router gets `csrf_protection_with_config` applied internally, //! a global `session_layer` is applied last (so it runs first, before CSRF), //! and the machine sub-router gets no CSRF layer. //! //! The SeaORM connection is a `MockDatabase`: the CSRF middleware ignores it //! functionally, and on the rejected/seam paths the handler (which would query //! it) is never reached. Building the inbox router needs `FediverseConfig` / //! `AuthConfig`, which load from environment variables; the few tests that //! build it set those vars in-process and are `#[serial]` to avoid races. use std::sync::Arc; use axum::{ Router, body::Body, http::{Method, StatusCode, header}, routing::get, }; 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 serial_test::serial; use tower::ServiceExt; // for `oneshot` use tower_sessions::{MemoryStore, SessionManagerLayer, cookie::SameSite}; /// Mock SeaORM connection. The CSRF middleware never queries it, and on the /// paths asserted here the downstream handler is never reached. fn mock_db() -> DatabaseConnection { MockDatabase::new(DatabaseBackend::Postgres).into_connection() } /// Session layer mirroring production cookie config closely enough for the /// token round-trip (non-secure so the cookie is accepted over plain HTTP). fn session_layer() -> SessionManagerLayer<MemoryStore> { SessionManagerLayer::new(MemoryStore::default()) .with_secure(false) .with_same_site(SameSite::Lax) } /// GET handler that primes a CSRF token into the session and returns it, so a /// test can capture both the session cookie and the token for replay. async fn token_handler(session: tower_sessions::Session) -> axum::response::Response { use axum::response::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(), } } /// Build the browser sub-router (CSRF applied internally) by merging the real /// fediverse-ui routes with a token-priming helper route, then add the global /// session layer — exactly the Lot 1 montage. fn browser_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> = rustwarden_fediverse_ui::default_routes() .route("/__test/token", get(token_handler)) .layer(axum::middleware::from_fn_with_state( (mock_db(), csrf_cfg), csrf_protection_with_config, )); browser_router.with_state(mock_db()).layer(session_layer()) } /// Extract the `id` session cookie value so it can be replayed. fn extract_session_cookie(resp: &axum::response::Response) -> Option<String> { let raw = resp .headers() .get(header::SET_COOKIE)? .to_str() .ok()? .to_string(); 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") } /// 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) } // --------------------------------------------------------------------------- // T10c-consent — the fediverse consent form is browser family and is protected // by the global CSRF layer. // --------------------------------------------------------------------------- /// `POST /fediverse/consent/{id}` without any CSRF token → 403, rejected by the /// layer *before* `consent_submit` runs (so the MockDatabase is never queried). #[tokio::test] async fn t10c_consent_post_without_token_is_403() { let app = browser_app(); let req = axum::http::Request::builder() .method(Method::POST) .uri("/fediverse/consent/00000000-0000-0000-0000-000000000000") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from("approve=true")) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!( resp.status(), StatusCode::FORBIDDEN, "consent POST without a CSRF token must be rejected by the browser layer" ); } /// With a valid session + form-body token, the CSRF layer no longer rejects the /// request. The downstream status depends on business logic / the mock DB, so /// we only assert it is **not** a 403 — i.e. the request cleared the CSRF gate /// and the buffered urlencoded body was reconstructed for the handler's /// `Form<…>` extractor (the same body double-read proven by core test T4). #[tokio::test] async fn t10c_consent_post_with_valid_form_token_is_not_csrf_rejected() { let app = browser_app(); let (cookie, token) = prime_token(&app).await; let body = format!("csrf_token={token}&approve=true&scopes=read"); let req = axum::http::Request::builder() .method(Method::POST) .uri("/fediverse/consent/00000000-0000-0000-0000-000000000000") .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(); let status = resp.status(); assert_ne!( status, StatusCode::FORBIDDEN, "a valid CSRF token must clear the layer; any 403 here would be a CSRF false-positive" ); // Discriminate the body double-read (A3.2): had the buffered urlencoded body // NOT been reconstructed, the downstream `Form<ConsentForm>` extractor would // fail with 400 (bad form) or 415 (unsupported media type). Asserting their // absence proves the handler actually received and parsed the replayed body, // not just that the CSRF gate let the request through. assert_ne!( status, StatusCode::BAD_REQUEST, "form body must be reparsed, not rejected as malformed" ); assert_ne!( status, StatusCode::UNSUPPORTED_MEDIA_TYPE, "reconstructed body must keep its urlencoded content-type for the Form extractor" ); } // --------------------------------------------------------------------------- // Machine inbox router construction (env-driven config). // --------------------------------------------------------------------------- /// Build the real fediverse inbox router on a machine sub-router (no CSRF /// layer). Sets the env vars `FediverseConfig` / `AuthConfig` require; callers /// must be `#[serial]` because env mutation is process-global. fn machine_app_with_inbox() -> Router { // SAFETY: the calling tests are serialised via `#[serial]`, so no other // thread observes this transient env state. The vars are only read during // router construction below. unsafe { std::env::set_var("FEDIVERSE_DOMAIN", "csrf-seam.test"); std::env::set_var("FEDIVERSE_DEBUG_LOCAL_FEDERATION", "false"); std::env::set_var("JWT_SECRET", "0123456789abcdef0123456789abcdef"); } let db = mock_db(); let config = rustwarden_fediverse::FediverseConfig::from_env() .expect("FediverseConfig::from_env with FEDIVERSE_DOMAIN set must succeed"); let auth_config = Arc::new( rustwarden_core::config::auth::AuthConfig::from_env() .expect("AuthConfig::from_env with JWT_SECRET set must succeed"), ); let resolver: Arc<dyn rustwarden_fediverse::resolver::LocalUserResolver> = Arc::new(rustwarden_fediverse::resolver::SeaOrmLocalUserResolver::new(db.clone())); let verifier: Arc< dyn rustwarden_fediverse::services::fediverse_signal_service::SignatureVerifier, > = Arc::new( rustwarden_fediverse::services::fediverse_signal_service::AlwaysAcceptVerifier::with_resolver( db.clone(), ), ); let machine_router = rustwarden_fediverse::routes::router_with_auth(config, resolver, verifier, auth_config); // No CSRF layer here: machine family. Global session layer mirrors prod. machine_router.with_state(db).layer(session_layer()) } // --------------------------------------------------------------------------- // T10c-inbox — the inbox is machine family: never 403 for a CSRF reason. // --------------------------------------------------------------------------- /// `POST /inbox` with no CSRF token is **never** rejected for a CSRF reason. It /// may legitimately be rejected for a missing/invalid HTTP signature (e.g. 400 /// / 401), but the machine sub-router carries no CSRF layer, so a 403 *for CSRF* /// is structurally impossible. We assert the status is not the CSRF 403 the /// browser layer would produce on an unsigned mutating POST. #[tokio::test] #[serial] async fn t10c_inbox_post_without_csrf_token_is_not_csrf_403() { let app = machine_app_with_inbox(); let req = axum::http::Request::builder() .method(Method::POST) .uri("/inbox") .header(header::CONTENT_TYPE, "application/activity+json") .body(Body::from("{}")) .unwrap(); let resp = app.oneshot(req).await.unwrap(); // The inbox may reject for signature reasons, but never with the CSRF 403. // (Were the inbox mistakenly on the browser channel, an unsigned POST with // no token would be a 403 here.) Documenting the distinction: a 403 *would* // be acceptable only if it came from signature policy, but // `AlwaysAcceptVerifier` accepts any signature, so any 403 observed here // could only originate from a CSRF layer — which must not exist. assert_ne!( resp.status(), StatusCode::FORBIDDEN, "inbox is machine family; it must never be 403 (no CSRF layer, signatures always accepted)" ); } // --------------------------------------------------------------------------- // T10d (structural) — the machine bundle actually carries `/inbox`. // --------------------------------------------------------------------------- /// The inbox route exists on the machine bundle and is reachable without any /// CSRF token: `POST /inbox` responds with something other than 404. This is /// the structural proof that the inbox lives in the machine family and is /// reachable on the CSRF-free channel (Annexe B requalified T10d). #[tokio::test] #[serial] async fn t10d_machine_bundle_exposes_inbox_route() { let app = machine_app_with_inbox(); let req = axum::http::Request::builder() .method(Method::POST) .uri("/inbox") .header(header::CONTENT_TYPE, "application/activity+json") .body(Body::from("{}")) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_ne!( resp.status(), StatusCode::NOT_FOUND, "the machine bundle must expose POST /inbox (route present, reachable without CSRF)" ); }
GitRust