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