Fix CSRF
EBO <eric.bouhana@softalys.com> committé le 2026-06-04 11:02
89bddbbecd8de3b61bb06cae845b1b20136adebd
1 parent(s)
17 fichiers modifiés
+1194
-78
M
Cargo.lock
+1
-0
@@ -4249,6 +4249,7 @@ dependencies = [
| 4249 | 4249 | "sea-orm", |
| 4250 | 4250 | "serde", |
| 4251 | 4251 | "serde_json", |
| 4252 | + "serial_test", | |
| 4252 | 4253 | "time", |
| 4253 | 4254 | "tokio", |
| 4254 | 4255 | "tower", |
M
app/examples/custom_hooks.rs
+2
-1
@@ -46,7 +46,8 @@ impl RustwardenHooks for MyAppHooks {
| 46 | 46 | async fn main() -> anyhow::Result<()> { |
| 47 | 47 | RustwardenApp::builder() |
| 48 | 48 | .from_env() |
| 49 | - .with_ui(rustwarden_ui::default_routes) | |
| 49 | + .with_ui(rustwarden_ui::browser_routes) | |
| 50 | + .merge_machine_routes(rustwarden_ui::api_routes()) | |
| 50 | 51 | .hooks(MyAppHooks) |
| 51 | 52 | .auto_migrate(true) |
| 52 | 53 | .build() |
M
app/examples/minimal_headless.rs
+1
-1
@@ -17,7 +17,7 @@ async fn main() -> anyhow::Result<()> {
| 17 | 17 | .from_env() |
| 18 | 18 | .headless() |
| 19 | 19 | .auto_migrate(true) |
| 20 | - .merge_routes(api_routes()) | |
| 20 | + .merge_machine_routes(api_routes()) | |
| 21 | 21 | .build() |
| 22 | 22 | .await? |
| 23 | 23 | .run() |
M
app/examples/with_default_ui.rs
+2
-1
@@ -10,7 +10,8 @@ use rustwarden_core::prelude::*;
| 10 | 10 | async fn main() -> anyhow::Result<()> { |
| 11 | 11 | RustwardenApp::builder() |
| 12 | 12 | .from_env() |
| 13 | - .with_ui(rustwarden_ui::default_routes) | |
| 13 | + .with_ui(rustwarden_ui::browser_routes) | |
| 14 | + .merge_machine_routes(rustwarden_ui::api_routes()) | |
| 14 | 15 | .auto_migrate(true) |
| 15 | 16 | .build() |
| 16 | 17 | .await? |
M
app/src/main.rs
+46
-17
@@ -6,7 +6,10 @@ use rustwarden_core::prelude::*;
| 6 | 6 | async fn main() -> anyhow::Result<()> { |
| 7 | 7 | let builder = RustwardenApp::builder() |
| 8 | 8 | .from_env() |
| 9 | - .with_ui(rustwarden_ui::default_routes); | |
| 9 | + // Browser SSR routes go through the CSRF-protected channel; the | |
| 10 | + // `/api/v1/*` JSON API is mounted on the machine channel (no CSRF). | |
| 11 | + .with_ui(rustwarden_ui::browser_routes) | |
| 12 | + .merge_machine_routes(rustwarden_ui::api_routes()); | |
| 10 | 13 | |
| 11 | 14 | // ActivityPub federation lives in the `rustwarden-fediverse` crate |
| 12 | 15 | // (cf. docs/plans/2026-05-11-extract-rustwarden-fediverse-crate.md). |
@@ -73,12 +76,14 @@ async fn main() -> anyhow::Result<()> {
| 73 | 76 | // signature verifier; absent layer ⇒ everything defaults to |
| 74 | 77 | // `NoopFediverseObserver` (zero-cost no-op). |
| 75 | 78 | // |
| 76 | - // When the UI sub-feature is also active, the same store and | |
| 77 | - // config are shared with the admin UI router so a single source | |
| 78 | - // of truth backs both the runtime ban-manager and the screens | |
| 79 | - // that surface its state. | |
| 79 | + // Lot 2 CSRF — the guard observer stays bolted onto the machine | |
| 80 | + // `fediverse_router` (it serves the inbox), but the guard admin UI | |
| 81 | + // is now split out into its own `guard_ui_router` so it can ride the | |
| 82 | + // CSRF-protected browser channel (see below). The store and config | |
| 83 | + // are still shared between the runtime ban-manager and the UI so a | |
| 84 | + // single source of truth backs both. | |
| 80 | 85 | #[cfg(feature = "fediverse-guard")] |
| 81 | - let fediverse_router = { | |
| 86 | + let (fediverse_router, guard_ui_router) = { | |
| 82 | 87 | use rustwarden_fediverse::observer::FediverseEventObserver; |
| 83 | 88 | use rustwarden_fediverse_guard::{ |
| 84 | 89 | BanManager, GuardConfig, GuardObserver, GuardStore, HybridStore, PostgresStore, |
@@ -100,14 +105,20 @@ async fn main() -> anyhow::Result<()> {
| 100 | 105 | let detectors = default_detectors(&guard_config); |
| 101 | 106 | let observer: Arc<dyn FediverseEventObserver> = |
| 102 | 107 | Arc::new(GuardObserver::new(manager, detectors, None)); |
| 103 | - // Lot 8 — mount the guard admin UI alongside, sharing the | |
| 104 | - // same store + config as the runtime observer so every | |
| 105 | - // screen surfaces live data. | |
| 106 | - fediverse_router.layer(axum::Extension(observer)).merge( | |
| 107 | - rustwarden_fediverse_guard::ui::default_routes() | |
| 108 | - .layer(axum::Extension(hybrid_store)) | |
| 109 | - .layer(axum::Extension(guard_config)), | |
| 110 | - ) | |
| 108 | + // Machine router: keep the inbox + observer here. The observer | |
| 109 | + // extension is read on the inbox path. | |
| 110 | + let fediverse_router = fediverse_router.layer(axum::Extension(observer)); | |
| 111 | + // Browser router: the guard admin UI. Its single POST | |
| 112 | + // (`/admin/fediverse-guard/manual_ban`) is a urlencoded form | |
| 113 | + // carrying a hidden `csrf_token`; the handler still extracts | |
| 114 | + // `Form { csrf_token }` and calls `validate_csrf_token` | |
| 115 | + // (defense-in-depth, D3=a), while the global browser CSRF layer | |
| 116 | + // validates the same token from the buffered body and rebuilds | |
| 117 | + // it for the downstream extractor (cf. core test T4). | |
| 118 | + let guard_ui_router = rustwarden_fediverse_guard::ui::default_routes() | |
| 119 | + .layer(axum::Extension(hybrid_store)) | |
| 120 | + .layer(axum::Extension(guard_config)); | |
| 121 | + (fediverse_router, guard_ui_router) | |
| 111 | 122 | }; |
| 112 | 123 | |
| 113 | 124 | let checker: Arc< |
@@ -117,15 +128,25 @@ async fn main() -> anyhow::Result<()> {
| 117 | 128 | db.clone(), |
| 118 | 129 | ), |
| 119 | 130 | ); |
| 131 | + // The fediverse router carries `POST /inbox` (machine, authenticated | |
| 132 | + // by HTTP signatures — no CSRF token possible), so it goes on the | |
| 133 | + // machine channel. | |
| 120 | 134 | let builder = builder |
| 121 | 135 | .database(db) |
| 122 | 136 | .register_protected_prefix("/fediverse/consent") |
| 123 | 137 | .register_protected_prefix("/fediverse/invite") |
| 124 | 138 | .register_revocation_checker(checker) |
| 125 | - .merge_routes(fediverse_router); | |
| 139 | + .merge_machine_routes(fediverse_router); | |
| 126 | 140 | |
| 141 | + // The guard admin UI is a browser family (SSR forms under | |
| 142 | + // `/admin/fediverse-guard/*`): mount it on the CSRF-protected | |
| 143 | + // browser channel and register its prefix so the auth middleware | |
| 144 | + // gates it like every other `/admin/*` screen. | |
| 127 | 145 | #[cfg(feature = "fediverse-guard")] |
| 128 | - let builder = builder.add_dashboard_card(rustwarden_fediverse_guard::ui::dashboard_card()); | |
| 146 | + let builder = builder | |
| 147 | + .register_protected_prefix("/admin/fediverse-guard") | |
| 148 | + .merge_browser_routes(guard_ui_router) | |
| 149 | + .add_dashboard_card(rustwarden_fediverse_guard::ui::dashboard_card()); | |
| 129 | 150 | |
| 130 | 151 | builder |
| 131 | 152 | }; |
@@ -137,7 +158,15 @@ async fn main() -> anyhow::Result<()> {
| 137 | 158 | // dashboard via the builder hook. |
| 138 | 159 | #[cfg(feature = "fediverse-ui")] |
| 139 | 160 | let builder = builder |
| 140 | - .merge_routes(rustwarden_fediverse_ui::default_routes()) | |
| 161 | + // Famille browser : login fediverse, consent, invite et écrans | |
| 162 | + // admin sont des formulaires SSR pilotés par cookie de session, on | |
| 163 | + // les monte donc sur le canal CSRF-protégé. Chaque handler conserve | |
| 164 | + // son `validate_csrf_token` en defense-in-depth (D3=a) ; le layer | |
| 165 | + // CSRF global lit le token dans le body urlencoded bufferisé puis | |
| 166 | + // reconstruit le body pour l'extracteur `Form<…>` aval (réf. test | |
| 167 | + // core T4). L'inbox ActivityPub n'est PAS ici : elle reste sur le | |
| 168 | + // canal machine via `fediverse_router`. | |
| 169 | + .merge_browser_routes(rustwarden_fediverse_ui::default_routes()) | |
| 141 | 170 | .add_dashboard_card(rustwarden_fediverse_ui::dashboard_card()); |
| 142 | 171 | |
| 143 | 172 | builder.build().await?.run().await |
M
crates/rustwarden-core/src/app.rs
+83
-11
@@ -43,6 +43,9 @@ use crate::i18n::loader::load_translations_from_directory;
| 43 | 43 | use crate::i18n::service::I18nService; |
| 44 | 44 | use crate::middleware::admin_service::AdminService; |
| 45 | 45 | use crate::middleware::auth_service::AuthService; |
| 46 | +use crate::middleware::csrf::{ | |
| 47 | + CsrfProtectionConfig, DEFAULT_MAX_FORM_BODY_BYTES, csrf_protection_with_config, | |
| 48 | +}; | |
| 46 | 49 | use crate::services::app_settings_service::AppSettingsService; |
| 47 | 50 | use crate::services::default_admin_service; |
| 48 | 51 |
@@ -124,7 +127,15 @@ pub struct RustwardenBuilder {
| 124 | 127 | database_url: Option<String>, |
| 125 | 128 | db: Option<DatabaseConnection>, |
| 126 | 129 | server_address: Option<String>, |
| 130 | + /// Browser-family routers (session-cookie driven UI). These receive the | |
| 131 | + /// CSRF protection layer before being merged into the application router. | |
| 132 | + /// Fed by [`RustwardenBuilder::merge_routes`] / | |
| 133 | + /// [`RustwardenBuilder::merge_browser_routes`]. | |
| 127 | 134 | custom_routes: Vec<Router<DatabaseConnection>>, |
| 135 | + /// Machine-family routers (JSON APIs, webhooks, ActivityPub inbox, OAuth | |
| 136 | + /// callbacks). These are merged *without* the CSRF layer. Fed by | |
| 137 | + /// [`RustwardenBuilder::merge_machine_routes`]. | |
| 138 | + machine_routes: Vec<Router<DatabaseConnection>>, | |
| 128 | 139 | ui_routes_fn: Option<RoutesFn>, |
| 129 | 140 | hooks: Arc<dyn RustwardenHooks>, |
| 130 | 141 | auto_migrate: bool, |
@@ -157,6 +168,7 @@ impl Default for RustwardenBuilder {
| 157 | 168 | db: None, |
| 158 | 169 | server_address: None, |
| 159 | 170 | custom_routes: Vec::new(), |
| 171 | + machine_routes: Vec::new(), | |
| 160 | 172 | ui_routes_fn: None, |
| 161 | 173 | hooks: Arc::new(NoopHooks), |
| 162 | 174 | auto_migrate: false, |
@@ -212,12 +224,43 @@ impl RustwardenBuilder {
| 212 | 224 | self |
| 213 | 225 | } |
| 214 | 226 | |
| 215 | - /// Merge additional custom routes into the application. | |
| 227 | + /// Merge additional custom routes into the **browser** family. | |
| 228 | + /// | |
| 229 | + /// Browser routes are session-cookie driven and therefore receive the | |
| 230 | + /// CSRF protection layer: any `POST`/`PUT`/`DELETE`/`PATCH` reaching a | |
| 231 | + /// route merged here is rejected with `403` unless it carries a valid | |
| 232 | + /// CSRF token (header `X-CSRF-Token` or form field `csrf_token`), or an | |
| 233 | + /// `Authorization: Bearer` header. This is the fail-safe default (D2=A): | |
| 234 | + /// a third-party mutating route inherits protection without extra code. | |
| 235 | + /// | |
| 236 | + /// Routes that authenticate via something other than a session cookie | |
| 237 | + /// (JSON APIs, webhooks, ActivityPub inbox, OAuth callbacks) must be | |
| 238 | + /// mounted via [`RustwardenBuilder::merge_machine_routes`] instead, or | |
| 239 | + /// they will receive spurious `403`s. | |
| 216 | 240 | pub fn merge_routes(mut self, router: Router<DatabaseConnection>) -> Self { |
| 217 | 241 | self.custom_routes.push(router); |
| 218 | 242 | self |
| 219 | 243 | } |
| 220 | 244 | |
| 245 | + /// Explicit alias of [`RustwardenBuilder::merge_routes`]: merge routes | |
| 246 | + /// into the **browser** family (CSRF-protected). Prefer this name at call | |
| 247 | + /// sites where the browser/machine distinction matters for readability. | |
| 248 | + pub fn merge_browser_routes(self, router: Router<DatabaseConnection>) -> Self { | |
| 249 | + self.merge_routes(router) | |
| 250 | + } | |
| 251 | + | |
| 252 | + /// Merge additional custom routes into the **machine** family. | |
| 253 | + /// | |
| 254 | + /// Machine routes are *not* covered by the CSRF layer. Use this channel | |
| 255 | + /// for endpoints that are not browser/session-cookie driven and thus not | |
| 256 | + /// CSRF-vulnerable: JSON APIs authenticated by `Authorization: Bearer`, | |
| 257 | + /// webhooks, OAuth callbacks, and the ActivityPub inbox (authenticated by | |
| 258 | + /// HTTP signatures, where no CSRF token can exist). | |
| 259 | + pub fn merge_machine_routes(mut self, router: Router<DatabaseConnection>) -> Self { | |
| 260 | + self.machine_routes.push(router); | |
| 261 | + self | |
| 262 | + } | |
| 263 | + | |
| 221 | 264 | /// Register the default UI routes (login, profile, admin panel). |
| 222 | 265 | /// Pass the function that returns the UI router (from `rustwarden_ui::default_routes`). |
| 223 | 266 | pub fn with_ui( |
@@ -441,18 +484,33 @@ impl RustwardenBuilder {
| 441 | 484 | Arc::new(registry) |
| 442 | 485 | }; |
| 443 | 486 | |
| 444 | - // 7. Build router | |
| 445 | - let mut router: Router<DatabaseConnection> = Router::new(); | |
| 446 | - | |
| 487 | + // 7. Build router — split into two families (cf. CSRF plan §4.1): | |
| 488 | + // * browser_router — UI / session-cookie driven, CSRF-protected. | |
| 489 | + // * machine_router — JSON APIs, webhooks, OAuth callbacks, the | |
| 490 | + // ActivityPub inbox: never CSRF-protected. | |
| 491 | + // The CSRF layer is applied to `browser_router` *before* the merge so | |
| 492 | + // it covers the browser family topologically and never leaks onto the | |
| 493 | + // machine family. The shared `session_layer` stays global (below) and | |
| 494 | + // therefore runs *before* the CSRF check, satisfying the `Session` | |
| 495 | + // extractor's prerequisite. | |
| 496 | + let db_for_csrf = db.clone(); | |
| 497 | + | |
| 498 | + let mut browser_router: Router<DatabaseConnection> = Router::new(); | |
| 447 | 499 | if let Some(ui_fn) = self.ui_routes_fn { |
| 448 | - router = router.merge(ui_fn()); | |
| 500 | + browser_router = browser_router.merge(ui_fn()); | |
| 449 | 501 | } |
| 450 | - | |
| 451 | 502 | for custom in self.custom_routes { |
| 452 | - router = router.merge(custom); | |
| 503 | + browser_router = browser_router.merge(custom); | |
| 504 | + } | |
| 505 | + | |
| 506 | + let mut machine_router: Router<DatabaseConnection> = Router::new(); | |
| 507 | + for machine in self.machine_routes { | |
| 508 | + machine_router = machine_router.merge(machine); | |
| 453 | 509 | } |
| 454 | 510 | |
| 455 | - // 7b. OAuth routes (conditional on feature + config) | |
| 511 | + // 7b. OAuth routes (conditional on feature + config) — machine family: | |
| 512 | + // JSON API, GETs are safe, the unlink DELETE is Bearer/JSON, never a | |
| 513 | + // browser form submission, so no CSRF token applies. | |
| 456 | 514 | #[cfg(feature = "oauth")] |
| 457 | 515 | { |
| 458 | 516 | use crate::auth::oauth_handlers; |
@@ -479,12 +537,26 @@ impl RustwardenBuilder {
| 479 | 537 | "/api/v1/auth/oauth/accounts/{id}", |
| 480 | 538 | axum::routing::delete(oauth_handlers::unlink_oauth_account), |
| 481 | 539 | ); |
| 482 | - router = router.merge(oauth_routes); | |
| 540 | + machine_router = machine_router.merge(oauth_routes); | |
| 483 | 541 | } |
| 484 | 542 | |
| 485 | 543 | // Federation routes now live in the `rustwarden-fediverse` crate; |
| 486 | - // consumers mount them themselves on the returned router (cf. | |
| 487 | - // `app/src/main.rs` calling `rustwarden_fediverse::routes::router`). | |
| 544 | + // consumers mount them themselves via `merge_machine_routes` | |
| 545 | + // (inbox) / `merge_browser_routes` (UI) — cf. `app/src/main.rs`. | |
| 546 | + | |
| 547 | + // Apply CSRF to the browser family only, before merging. Layers | |
| 548 | + // attached to a sub-router are preserved across `Router::merge`, so | |
| 549 | + // the machine family stays free of the CSRF check. | |
| 550 | + let csrf_cfg = Arc::new(CsrfProtectionConfig { | |
| 551 | + accept_form_body: true, | |
| 552 | + max_form_body_bytes: DEFAULT_MAX_FORM_BODY_BYTES, | |
| 553 | + }); | |
| 554 | + let browser_router = browser_router.layer(axum::middleware::from_fn_with_state( | |
| 555 | + (db_for_csrf, csrf_cfg), | |
| 556 | + csrf_protection_with_config, | |
| 557 | + )); | |
| 558 | + | |
| 559 | + let router = browser_router.merge(machine_router); | |
| 488 | 560 | |
| 489 | 561 | // 8. Apply middleware stack |
| 490 | 562 | let supported = self.supported_locales.clone(); |
M
crates/rustwarden-core/src/middleware/csrf.rs
+21
-0
@@ -437,6 +437,27 @@ mod tests {
| 437 | 437 | assert_ne!(token1, token2); // Tokens should be different |
| 438 | 438 | } |
| 439 | 439 | |
| 440 | + // ---------- T12: constant-time token comparison ---------- | |
| 441 | + | |
| 442 | + #[test] | |
| 443 | + fn csrf_tokens_match_is_true_for_equal_tokens() { | |
| 444 | + let token = generate_csrf_token(); | |
| 445 | + assert!(csrf_tokens_match(&token, &token)); | |
| 446 | + } | |
| 447 | + | |
| 448 | + #[test] | |
| 449 | + fn csrf_tokens_match_is_false_for_different_tokens() { | |
| 450 | + let a = generate_csrf_token(); | |
| 451 | + let b = generate_csrf_token(); | |
| 452 | + assert!(!csrf_tokens_match(&a, &b)); | |
| 453 | + } | |
| 454 | + | |
| 455 | + #[test] | |
| 456 | + fn csrf_tokens_match_is_false_for_length_mismatch() { | |
| 457 | + // `ct_eq` over byte slices of differing lengths must not match. | |
| 458 | + assert!(!csrf_tokens_match("abc", "abcd")); | |
| 459 | + } | |
| 460 | + | |
| 440 | 461 | fn test_session() -> Session { |
| 441 | 462 | Session::new(None, Arc::new(MemoryStore::default()), None) |
| 442 | 463 | } |
M
crates/rustwarden-core/src/middleware/mod.rs
+10
-0
@@ -18,6 +18,16 @@ pub mod auth_service;
| 18 | 18 | /// Key entry points: [`crate::middleware::csrf::csrf_protection`], |
| 19 | 19 | /// [`crate::middleware::csrf::csrf_protection_with_config`], |
| 20 | 20 | /// and [`crate::middleware::csrf::CsrfProtectionConfig`]. |
| 21 | +/// | |
| 22 | +/// Wiring: `RustwardenBuilder::build` applies | |
| 23 | +/// [`crate::middleware::csrf::csrf_protection_with_config`] | |
| 24 | +/// (`accept_form_body = true`) to the **browser** router only — the family | |
| 25 | +/// fed by `with_ui` / `merge_browser_routes` / `merge_routes`. The **machine** | |
| 26 | +/// router (`merge_machine_routes`, OAuth callbacks, the ActivityPub inbox) is | |
| 27 | +/// never covered, so HTTP-signature and Bearer endpoints are not subject to | |
| 28 | +/// CSRF. The CSRF layer is internal to the browser sub-router while the | |
| 29 | +/// `session_layer` is global, guaranteeing the session is available when the | |
| 30 | +/// CSRF check runs. See `docs/plans/2026-06-04-csrf-protection-structurelle.md`. | |
| 21 | 31 | pub mod csrf; |
| 22 | 32 | /// Centralized error handling middleware that maps errors to structured responses. |
| 23 | 33 | pub mod error_handler; |
A
crates/rustwarden-core/tests/csrf_protection.rs
+308
-0
@@ -0,0 +1,308 @@
| 1 | +//! Integration tests for the structural CSRF protection wiring (Lot 1). | |
| 2 | +//! | |
| 3 | +//! These tests reproduce the browser/machine router split performed by | |
| 4 | +//! `RustwardenBuilder::build` (cf. `crates/rustwarden-core/src/app.rs` and | |
| 5 | +//! `docs/plans/2026-06-04-csrf-protection-structurelle.md`): | |
| 6 | +//! | |
| 7 | +//! * `browser_router` carries the CSRF layer | |
| 8 | +//! ([`csrf_protection_with_config`]) *and* a global `session_layer`. The | |
| 9 | +//! session layer is applied last (outermost) so it runs **before** the CSRF | |
| 10 | +//! check, matching production where `session_layer` is global and the CSRF | |
| 11 | +//! layer is internal to the browser sub-router. | |
| 12 | +//! * `machine_router` carries the session layer but no CSRF layer. | |
| 13 | +//! | |
| 14 | +//! The database connection threaded into the CSRF state is a SeaORM mock: the | |
| 15 | +//! middleware ignores it functionally (`State((_db, cfg))`), so no live | |
| 16 | +//! database is required. | |
| 17 | + | |
| 18 | +use axum::{ | |
| 19 | + Router, | |
| 20 | + body::Body, | |
| 21 | + http::{Method, StatusCode, header}, | |
| 22 | + response::IntoResponse, | |
| 23 | + routing::{get, post}, | |
| 24 | +}; | |
| 25 | +use rustwarden_core::middleware::csrf::{ | |
| 26 | + CsrfProtectionConfig, DEFAULT_MAX_FORM_BODY_BYTES, csrf_protection_with_config, | |
| 27 | + get_or_create_csrf_token, | |
| 28 | +}; | |
| 29 | +use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; | |
| 30 | +use std::sync::Arc; | |
| 31 | +use tower::ServiceExt; // for `oneshot` | |
| 32 | +use tower_sessions::{MemoryStore, SessionManagerLayer, cookie::SameSite}; | |
| 33 | + | |
| 34 | +/// Build a mock SeaORM connection. The CSRF middleware never queries it. | |
| 35 | +fn mock_db() -> DatabaseConnection { | |
| 36 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection() | |
| 37 | +} | |
| 38 | + | |
| 39 | +/// Session layer mirroring the production cookie config closely enough for the | |
| 40 | +/// token round-trip (non-secure so the cookie is accepted over plain HTTP in | |
| 41 | +/// tests). | |
| 42 | +fn session_layer() -> SessionManagerLayer<MemoryStore> { | |
| 43 | + SessionManagerLayer::new(MemoryStore::default()) | |
| 44 | + .with_secure(false) | |
| 45 | + .with_same_site(SameSite::Lax) | |
| 46 | +} | |
| 47 | + | |
| 48 | +/// Echo handler with **no** CSRF code of its own — proves the protection is | |
| 49 | +/// structural (T10). Returns 200 with a fixed body when reached. | |
| 50 | +async fn echo_handler() -> impl IntoResponse { | |
| 51 | + (StatusCode::OK, "echoed") | |
| 52 | +} | |
| 53 | + | |
| 54 | +/// Form-extracting handler with **no** CSRF code of its own. Reads its | |
| 55 | +/// `Form<HashMap<..>>` *after* the middleware has buffered and reconstructed | |
| 56 | +/// the body, and echoes back the `field` value. Used by T4 to prove the body | |
| 57 | +/// is genuinely replayed to downstream extractors (piège n°5). | |
| 58 | +async fn form_echo_handler( | |
| 59 | + axum::Form(form): axum::Form<std::collections::HashMap<String, String>>, | |
| 60 | +) -> impl IntoResponse { | |
| 61 | + match form.get("field") { | |
| 62 | + Some(v) => (StatusCode::OK, format!("field={v}")).into_response(), | |
| 63 | + None => (StatusCode::UNPROCESSABLE_ENTITY, "missing field").into_response(), | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +/// GET handler that primes the session CSRF token and returns it in the body, | |
| 68 | +/// so a test can capture both the session cookie and the token for replay. | |
| 69 | +async fn token_handler(session: tower_sessions::Session) -> impl IntoResponse { | |
| 70 | + match get_or_create_csrf_token(&session).await { | |
| 71 | + Ok(token) => (StatusCode::OK, token).into_response(), | |
| 72 | + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response(), | |
| 73 | + } | |
| 74 | +} | |
| 75 | + | |
| 76 | +/// Assemble the test app reproducing the browser/machine split. | |
| 77 | +/// | |
| 78 | +/// Browser routes: `/__test/token` (GET, primes token), `/__test/echo` | |
| 79 | +/// (POST, no CSRF code). Machine routes: `/__machine/echo` (POST). | |
| 80 | +fn test_app() -> Router { | |
| 81 | + let csrf_cfg = Arc::new(CsrfProtectionConfig { | |
| 82 | + accept_form_body: true, | |
| 83 | + max_form_body_bytes: DEFAULT_MAX_FORM_BODY_BYTES, | |
| 84 | + }); | |
| 85 | + | |
| 86 | + let browser_router: Router<DatabaseConnection> = Router::new() | |
| 87 | + .route("/__test/token", get(token_handler)) | |
| 88 | + .route("/__test/echo", post(echo_handler)) | |
| 89 | + .route("/__test/form", post(form_echo_handler)) | |
| 90 | + .layer(axum::middleware::from_fn_with_state( | |
| 91 | + (mock_db(), csrf_cfg), | |
| 92 | + csrf_protection_with_config, | |
| 93 | + )); | |
| 94 | + | |
| 95 | + let machine_router: Router<DatabaseConnection> = | |
| 96 | + Router::new().route("/__machine/echo", post(echo_handler)); | |
| 97 | + | |
| 98 | + browser_router | |
| 99 | + .merge(machine_router) | |
| 100 | + .with_state(mock_db()) | |
| 101 | + .layer(session_layer()) | |
| 102 | +} | |
| 103 | + | |
| 104 | +/// Extract the `id` session cookie value (the `Set-Cookie` header set by | |
| 105 | +/// tower-sessions) so it can be replayed on a subsequent request. | |
| 106 | +fn extract_session_cookie(resp: &axum::response::Response) -> Option<String> { | |
| 107 | + let raw = resp | |
| 108 | + .headers() | |
| 109 | + .get(header::SET_COOKIE)? | |
| 110 | + .to_str() | |
| 111 | + .ok()? | |
| 112 | + .to_string(); | |
| 113 | + // Keep only the `name=value` pair (before the first `;`). | |
| 114 | + Some(raw.split(';').next()?.to_string()) | |
| 115 | +} | |
| 116 | + | |
| 117 | +async fn body_string(resp: axum::response::Response) -> String { | |
| 118 | + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) | |
| 119 | + .await | |
| 120 | + .expect("body read"); | |
| 121 | + String::from_utf8(bytes.to_vec()).expect("utf8 body") | |
| 122 | +} | |
| 123 | + | |
| 124 | +/// Perform the GET that primes the token, returning `(session_cookie, token)`. | |
| 125 | +async fn prime_token(app: &Router) -> (String, String) { | |
| 126 | + let req = axum::http::Request::builder() | |
| 127 | + .method(Method::GET) | |
| 128 | + .uri("/__test/token") | |
| 129 | + .body(Body::empty()) | |
| 130 | + .expect("request"); | |
| 131 | + let resp = app.clone().oneshot(req).await.expect("oneshot"); | |
| 132 | + assert_eq!(resp.status(), StatusCode::OK, "token priming GET must succeed"); | |
| 133 | + let cookie = extract_session_cookie(&resp).expect("session cookie set"); | |
| 134 | + let token = body_string(resp).await; | |
| 135 | + assert_eq!(token.len(), 64, "token must be 64 hex chars"); | |
| 136 | + (cookie, token) | |
| 137 | +} | |
| 138 | + | |
| 139 | +// --------------------------------------------------------------------------- | |
| 140 | +// T5 — Safe methods are never blocked. | |
| 141 | +// --------------------------------------------------------------------------- | |
| 142 | +#[tokio::test] | |
| 143 | +async fn t5_get_browser_route_without_token_is_not_403() { | |
| 144 | + let app = test_app(); | |
| 145 | + let req = axum::http::Request::builder() | |
| 146 | + .method(Method::GET) | |
| 147 | + .uri("/__test/token") | |
| 148 | + .body(Body::empty()) | |
| 149 | + .unwrap(); | |
| 150 | + let resp = app.oneshot(req).await.unwrap(); | |
| 151 | + assert_ne!(resp.status(), StatusCode::FORBIDDEN); | |
| 152 | +} | |
| 153 | + | |
| 154 | +// --------------------------------------------------------------------------- | |
| 155 | +// T1 — POST on a browser route without a token → 403. | |
| 156 | +// --------------------------------------------------------------------------- | |
| 157 | +#[tokio::test] | |
| 158 | +async fn t1_post_browser_route_without_token_is_403() { | |
| 159 | + let app = test_app(); | |
| 160 | + let req = axum::http::Request::builder() | |
| 161 | + .method(Method::POST) | |
| 162 | + .uri("/__test/echo") | |
| 163 | + .body(Body::empty()) | |
| 164 | + .unwrap(); | |
| 165 | + let resp = app.oneshot(req).await.unwrap(); | |
| 166 | + assert_eq!(resp.status(), StatusCode::FORBIDDEN); | |
| 167 | +} | |
| 168 | + | |
| 169 | +// --------------------------------------------------------------------------- | |
| 170 | +// T2 — POST with an invalid X-CSRF-Token → 403. | |
| 171 | +// --------------------------------------------------------------------------- | |
| 172 | +#[tokio::test] | |
| 173 | +async fn t2_post_browser_route_with_invalid_token_is_403() { | |
| 174 | + let app = test_app(); | |
| 175 | + // Prime a session so a *real* session token exists to mismatch against. | |
| 176 | + let (cookie, _token) = prime_token(&app).await; | |
| 177 | + let req = axum::http::Request::builder() | |
| 178 | + .method(Method::POST) | |
| 179 | + .uri("/__test/echo") | |
| 180 | + .header(header::COOKIE, cookie) | |
| 181 | + .header("X-CSRF-Token", "not-the-right-token") | |
| 182 | + .body(Body::empty()) | |
| 183 | + .unwrap(); | |
| 184 | + let resp = app.oneshot(req).await.unwrap(); | |
| 185 | + assert_eq!(resp.status(), StatusCode::FORBIDDEN); | |
| 186 | +} | |
| 187 | + | |
| 188 | +// --------------------------------------------------------------------------- | |
| 189 | +// T6 — Bearer-authed POST without a CSRF token bypasses the check. | |
| 190 | +// --------------------------------------------------------------------------- | |
| 191 | +#[tokio::test] | |
| 192 | +async fn t6_post_browser_route_with_bearer_is_not_403() { | |
| 193 | + let app = test_app(); | |
| 194 | + let req = axum::http::Request::builder() | |
| 195 | + .method(Method::POST) | |
| 196 | + .uri("/__test/echo") | |
| 197 | + .header(header::AUTHORIZATION, "Bearer some.jwt.token") | |
| 198 | + .body(Body::empty()) | |
| 199 | + .unwrap(); | |
| 200 | + let resp = app.oneshot(req).await.unwrap(); | |
| 201 | + assert_ne!(resp.status(), StatusCode::FORBIDDEN); | |
| 202 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 203 | +} | |
| 204 | + | |
| 205 | +// --------------------------------------------------------------------------- | |
| 206 | +// T10 — Secure-by-default: a POST route with no CSRF code in its handler is | |
| 207 | +// still rejected without a token and accepted with a valid one. | |
| 208 | +// --------------------------------------------------------------------------- | |
| 209 | +#[tokio::test] | |
| 210 | +async fn t10_echo_route_without_handler_csrf_code_is_protected() { | |
| 211 | + let app = test_app(); | |
| 212 | + | |
| 213 | + // Without a token → 403. | |
| 214 | + let req = axum::http::Request::builder() | |
| 215 | + .method(Method::POST) | |
| 216 | + .uri("/__test/echo") | |
| 217 | + .body(Body::empty()) | |
| 218 | + .unwrap(); | |
| 219 | + let resp = app.clone().oneshot(req).await.unwrap(); | |
| 220 | + assert_eq!(resp.status(), StatusCode::FORBIDDEN, "no token must be rejected"); | |
| 221 | + | |
| 222 | + // With a valid token (header channel) → 200. | |
| 223 | + let (cookie, token) = prime_token(&app).await; | |
| 224 | + let req = axum::http::Request::builder() | |
| 225 | + .method(Method::POST) | |
| 226 | + .uri("/__test/echo") | |
| 227 | + .header(header::COOKIE, cookie) | |
| 228 | + .header("X-CSRF-Token", token) | |
| 229 | + .body(Body::empty()) | |
| 230 | + .unwrap(); | |
| 231 | + let resp = app.oneshot(req).await.unwrap(); | |
| 232 | + assert_eq!(resp.status(), StatusCode::OK, "valid token must pass"); | |
| 233 | +} | |
| 234 | + | |
| 235 | +// --------------------------------------------------------------------------- | |
| 236 | +// T3 — Success via the X-CSRF-Token header (round-trip session + token). | |
| 237 | +// --------------------------------------------------------------------------- | |
| 238 | +#[tokio::test] | |
| 239 | +async fn t3_post_browser_route_with_valid_header_token_succeeds() { | |
| 240 | + let app = test_app(); | |
| 241 | + let (cookie, token) = prime_token(&app).await; | |
| 242 | + let req = axum::http::Request::builder() | |
| 243 | + .method(Method::POST) | |
| 244 | + .uri("/__test/echo") | |
| 245 | + .header(header::COOKIE, cookie) | |
| 246 | + .header("X-CSRF-Token", token) | |
| 247 | + .body(Body::empty()) | |
| 248 | + .unwrap(); | |
| 249 | + let resp = app.oneshot(req).await.unwrap(); | |
| 250 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 251 | +} | |
| 252 | + | |
| 253 | +// --------------------------------------------------------------------------- | |
| 254 | +// T4 — Success via the form body channel (`csrf_token=<valid>`), proving the | |
| 255 | +// buffered body is reconstructed for downstream handlers. | |
| 256 | +// --------------------------------------------------------------------------- | |
| 257 | +#[tokio::test] | |
| 258 | +async fn t4_post_browser_route_with_valid_form_body_token_succeeds() { | |
| 259 | + let app = test_app(); | |
| 260 | + let (cookie, token) = prime_token(&app).await; | |
| 261 | + let body = format!("csrf_token={token}&field=value"); | |
| 262 | + let req = axum::http::Request::builder() | |
| 263 | + .method(Method::POST) | |
| 264 | + .uri("/__test/form") | |
| 265 | + .header(header::COOKIE, cookie) | |
| 266 | + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 267 | + .body(Body::from(body)) | |
| 268 | + .unwrap(); | |
| 269 | + let resp = app.oneshot(req).await.unwrap(); | |
| 270 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 271 | + // Prove the body was reconstructed: the downstream `Form<T>` extractor | |
| 272 | + // still sees `field=value` after the middleware buffered the body to read | |
| 273 | + // the CSRF token (piège n°5). | |
| 274 | + assert_eq!(body_string(resp).await, "field=value"); | |
| 275 | +} | |
| 276 | + | |
| 277 | +// --------------------------------------------------------------------------- | |
| 278 | +// T10a / T10b — Machine router has no CSRF layer: a POST is never 403 for a | |
| 279 | +// CSRF reason, with or without a token. | |
| 280 | +// --------------------------------------------------------------------------- | |
| 281 | +#[tokio::test] | |
| 282 | +async fn t10a_machine_route_post_without_token_is_not_403() { | |
| 283 | + let app = test_app(); | |
| 284 | + let req = axum::http::Request::builder() | |
| 285 | + .method(Method::POST) | |
| 286 | + .uri("/__machine/echo") | |
| 287 | + .body(Body::empty()) | |
| 288 | + .unwrap(); | |
| 289 | + let resp = app.oneshot(req).await.unwrap(); | |
| 290 | + assert_ne!(resp.status(), StatusCode::FORBIDDEN); | |
| 291 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 292 | +} | |
| 293 | + | |
| 294 | +#[tokio::test] | |
| 295 | +async fn t10b_machine_route_post_with_session_cookie_is_not_403() { | |
| 296 | + let app = test_app(); | |
| 297 | + // Even carrying a session cookie (browser-like) the machine route is open. | |
| 298 | + let (cookie, _token) = prime_token(&app).await; | |
| 299 | + let req = axum::http::Request::builder() | |
| 300 | + .method(Method::POST) | |
| 301 | + .uri("/__machine/echo") | |
| 302 | + .header(header::COOKIE, cookie) | |
| 303 | + .body(Body::empty()) | |
| 304 | + .unwrap(); | |
| 305 | + let resp = app.oneshot(req).await.unwrap(); | |
| 306 | + assert_ne!(resp.status(), StatusCode::FORBIDDEN); | |
| 307 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 308 | +} |
M
crates/rustwarden-fediverse-ui/Cargo.toml
+10
-0
@@ -26,5 +26,15 @@ axum-extra = { version = "0.12", features = ["cookie"] }
| 26 | 26 | validator = { version = "0.20", features = ["derive"] } |
| 27 | 27 | time = { version = "0.3", features = ["macros", "serde"] } |
| 28 | 28 | |
| 29 | +[dev-dependencies] | |
| 30 | +# `mock` lets the CSRF-seam tests build the real fediverse routers against a | |
| 31 | +# `MockDatabase` (the CSRF layer runs before any handler, so no live DB is | |
| 32 | +# hit); `tower/util` provides `ServiceExt::oneshot`; `serial_test` serialises | |
| 33 | +# the process-global env mutations needed to load `FediverseConfig` / | |
| 34 | +# `AuthConfig`. | |
| 35 | +sea-orm = { version = "2.0.0-rc.20", features = ["mock"] } | |
| 36 | +tower = { version = "0.5", features = ["limit", "util"] } | |
| 37 | +serial_test = "3" | |
| 38 | + | |
| 29 | 39 | [lints] |
| 30 | 40 | workspace = true |
A
crates/rustwarden-fediverse-ui/tests/csrf_seam.rs
+276
-0
@@ -0,0 +1,276 @@
| 1 | +//! Inter-crate CSRF seam tests for the federation routers (Lot 2). | |
| 2 | +//! | |
| 3 | +//! These tests prove the *real* fediverse routers sit on the right side of the | |
| 4 | +//! browser/machine split performed by `app/src/main.rs`: | |
| 5 | +//! | |
| 6 | +//! * `rustwarden_fediverse_ui::default_routes()` (login / consent / invite / | |
| 7 | +//! admin SSR forms) is the **browser** family → it must ride the CSRF layer. | |
| 8 | +//! A mutating `POST` without a token is rejected with **403** *before the | |
| 9 | +//! handler runs* (the layer is outermost on the sub-router, so no live | |
| 10 | +//! database is ever touched on the rejected path). | |
| 11 | +//! * The fediverse inbox router (`POST /inbox`, authenticated by HTTP | |
| 12 | +//! signatures) is the **machine** family → it carries **no** CSRF layer, so | |
| 13 | +//! it is never rejected for a CSRF reason. | |
| 14 | +//! | |
| 15 | +//! The mount mirrors the Lot 1 wiring (`crates/rustwarden-core/tests/csrf_protection.rs`): | |
| 16 | +//! the browser sub-router gets `csrf_protection_with_config` applied internally, | |
| 17 | +//! a global `session_layer` is applied last (so it runs first, before CSRF), | |
| 18 | +//! and the machine sub-router gets no CSRF layer. | |
| 19 | +//! | |
| 20 | +//! The SeaORM connection is a `MockDatabase`: the CSRF middleware ignores it | |
| 21 | +//! functionally, and on the rejected/seam paths the handler (which would query | |
| 22 | +//! it) is never reached. Building the inbox router needs `FediverseConfig` / | |
| 23 | +//! `AuthConfig`, which load from environment variables; the few tests that | |
| 24 | +//! build it set those vars in-process and are `#[serial]` to avoid races. | |
| 25 | + | |
| 26 | +use std::sync::Arc; | |
| 27 | + | |
| 28 | +use axum::{ | |
| 29 | + Router, | |
| 30 | + body::Body, | |
| 31 | + http::{Method, StatusCode, header}, | |
| 32 | + routing::get, | |
| 33 | +}; | |
| 34 | +use rustwarden_core::middleware::csrf::{ | |
| 35 | + CsrfProtectionConfig, DEFAULT_MAX_FORM_BODY_BYTES, csrf_protection_with_config, | |
| 36 | + get_or_create_csrf_token, | |
| 37 | +}; | |
| 38 | +use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; | |
| 39 | +use serial_test::serial; | |
| 40 | +use tower::ServiceExt; // for `oneshot` | |
| 41 | +use tower_sessions::{MemoryStore, SessionManagerLayer, cookie::SameSite}; | |
| 42 | + | |
| 43 | +/// Mock SeaORM connection. The CSRF middleware never queries it, and on the | |
| 44 | +/// paths asserted here the downstream handler is never reached. | |
| 45 | +fn mock_db() -> DatabaseConnection { | |
| 46 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection() | |
| 47 | +} | |
| 48 | + | |
| 49 | +/// Session layer mirroring production cookie config closely enough for the | |
| 50 | +/// token round-trip (non-secure so the cookie is accepted over plain HTTP). | |
| 51 | +fn session_layer() -> SessionManagerLayer<MemoryStore> { | |
| 52 | + SessionManagerLayer::new(MemoryStore::default()) | |
| 53 | + .with_secure(false) | |
| 54 | + .with_same_site(SameSite::Lax) | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// GET handler that primes a CSRF token into the session and returns it, so a | |
| 58 | +/// test can capture both the session cookie and the token for replay. | |
| 59 | +async fn token_handler(session: tower_sessions::Session) -> axum::response::Response { | |
| 60 | + use axum::response::IntoResponse; | |
| 61 | + match get_or_create_csrf_token(&session).await { | |
| 62 | + Ok(token) => (StatusCode::OK, token).into_response(), | |
| 63 | + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "token error").into_response(), | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +/// Build the browser sub-router (CSRF applied internally) by merging the real | |
| 68 | +/// fediverse-ui routes with a token-priming helper route, then add the global | |
| 69 | +/// session layer — exactly the Lot 1 montage. | |
| 70 | +fn browser_app() -> Router { | |
| 71 | + let csrf_cfg = Arc::new(CsrfProtectionConfig { | |
| 72 | + accept_form_body: true, | |
| 73 | + max_form_body_bytes: DEFAULT_MAX_FORM_BODY_BYTES, | |
| 74 | + }); | |
| 75 | + | |
| 76 | + let browser_router: Router<DatabaseConnection> = rustwarden_fediverse_ui::default_routes() | |
| 77 | + .route("/__test/token", get(token_handler)) | |
| 78 | + .layer(axum::middleware::from_fn_with_state( | |
| 79 | + (mock_db(), csrf_cfg), | |
| 80 | + csrf_protection_with_config, | |
| 81 | + )); | |
| 82 | + | |
| 83 | + browser_router | |
| 84 | + .with_state(mock_db()) | |
| 85 | + .layer(session_layer()) | |
| 86 | +} | |
| 87 | + | |
| 88 | +/// Extract the `id` session cookie value so it can be replayed. | |
| 89 | +fn extract_session_cookie(resp: &axum::response::Response) -> Option<String> { | |
| 90 | + let raw = resp.headers().get(header::SET_COOKIE)?.to_str().ok()?.to_string(); | |
| 91 | + Some(raw.split(';').next()?.to_string()) | |
| 92 | +} | |
| 93 | + | |
| 94 | +async fn body_string(resp: axum::response::Response) -> String { | |
| 95 | + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) | |
| 96 | + .await | |
| 97 | + .expect("body read"); | |
| 98 | + String::from_utf8(bytes.to_vec()).expect("utf8 body") | |
| 99 | +} | |
| 100 | + | |
| 101 | +/// GET that primes the token, returning `(session_cookie, token)`. | |
| 102 | +async fn prime_token(app: &Router) -> (String, String) { | |
| 103 | + let req = axum::http::Request::builder() | |
| 104 | + .method(Method::GET) | |
| 105 | + .uri("/__test/token") | |
| 106 | + .body(Body::empty()) | |
| 107 | + .expect("request"); | |
| 108 | + let resp = app.clone().oneshot(req).await.expect("oneshot"); | |
| 109 | + assert_eq!(resp.status(), StatusCode::OK, "token priming GET must succeed"); | |
| 110 | + let cookie = extract_session_cookie(&resp).expect("session cookie set"); | |
| 111 | + let token = body_string(resp).await; | |
| 112 | + assert_eq!(token.len(), 64, "token must be 64 hex chars"); | |
| 113 | + (cookie, token) | |
| 114 | +} | |
| 115 | + | |
| 116 | +// --------------------------------------------------------------------------- | |
| 117 | +// T10c-consent — the fediverse consent form is browser family and is protected | |
| 118 | +// by the global CSRF layer. | |
| 119 | +// --------------------------------------------------------------------------- | |
| 120 | + | |
| 121 | +/// `POST /fediverse/consent/{id}` without any CSRF token → 403, rejected by the | |
| 122 | +/// layer *before* `consent_submit` runs (so the MockDatabase is never queried). | |
| 123 | +#[tokio::test] | |
| 124 | +async fn t10c_consent_post_without_token_is_403() { | |
| 125 | + let app = browser_app(); | |
| 126 | + let req = axum::http::Request::builder() | |
| 127 | + .method(Method::POST) | |
| 128 | + .uri("/fediverse/consent/00000000-0000-0000-0000-000000000000") | |
| 129 | + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 130 | + .body(Body::from("approve=true")) | |
| 131 | + .unwrap(); | |
| 132 | + let resp = app.oneshot(req).await.unwrap(); | |
| 133 | + assert_eq!( | |
| 134 | + resp.status(), | |
| 135 | + StatusCode::FORBIDDEN, | |
| 136 | + "consent POST without a CSRF token must be rejected by the browser layer" | |
| 137 | + ); | |
| 138 | +} | |
| 139 | + | |
| 140 | +/// With a valid session + form-body token, the CSRF layer no longer rejects the | |
| 141 | +/// request. The downstream status depends on business logic / the mock DB, so | |
| 142 | +/// we only assert it is **not** a 403 — i.e. the request cleared the CSRF gate | |
| 143 | +/// and the buffered urlencoded body was reconstructed for the handler's | |
| 144 | +/// `Form<…>` extractor (the same body double-read proven by core test T4). | |
| 145 | +#[tokio::test] | |
| 146 | +async fn t10c_consent_post_with_valid_form_token_is_not_csrf_rejected() { | |
| 147 | + let app = browser_app(); | |
| 148 | + let (cookie, token) = prime_token(&app).await; | |
| 149 | + let body = format!("csrf_token={token}&approve=true&scopes=read"); | |
| 150 | + let req = axum::http::Request::builder() | |
| 151 | + .method(Method::POST) | |
| 152 | + .uri("/fediverse/consent/00000000-0000-0000-0000-000000000000") | |
| 153 | + .header(header::COOKIE, cookie) | |
| 154 | + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 155 | + .body(Body::from(body)) | |
| 156 | + .unwrap(); | |
| 157 | + let resp = app.oneshot(req).await.unwrap(); | |
| 158 | + let status = resp.status(); | |
| 159 | + assert_ne!( | |
| 160 | + status, | |
| 161 | + StatusCode::FORBIDDEN, | |
| 162 | + "a valid CSRF token must clear the layer; any 403 here would be a CSRF false-positive" | |
| 163 | + ); | |
| 164 | + // Discriminate the body double-read (A3.2): had the buffered urlencoded body | |
| 165 | + // NOT been reconstructed, the downstream `Form<ConsentForm>` extractor would | |
| 166 | + // fail with 400 (bad form) or 415 (unsupported media type). Asserting their | |
| 167 | + // absence proves the handler actually received and parsed the replayed body, | |
| 168 | + // not just that the CSRF gate let the request through. | |
| 169 | + assert_ne!(status, StatusCode::BAD_REQUEST, "form body must be reparsed, not rejected as malformed"); | |
| 170 | + assert_ne!( | |
| 171 | + status, | |
| 172 | + StatusCode::UNSUPPORTED_MEDIA_TYPE, | |
| 173 | + "reconstructed body must keep its urlencoded content-type for the Form extractor" | |
| 174 | + ); | |
| 175 | +} | |
| 176 | + | |
| 177 | +// --------------------------------------------------------------------------- | |
| 178 | +// Machine inbox router construction (env-driven config). | |
| 179 | +// --------------------------------------------------------------------------- | |
| 180 | + | |
| 181 | +/// Build the real fediverse inbox router on a machine sub-router (no CSRF | |
| 182 | +/// layer). Sets the env vars `FediverseConfig` / `AuthConfig` require; callers | |
| 183 | +/// must be `#[serial]` because env mutation is process-global. | |
| 184 | +fn machine_app_with_inbox() -> Router { | |
| 185 | + // SAFETY: the calling tests are serialised via `#[serial]`, so no other | |
| 186 | + // thread observes this transient env state. The vars are only read during | |
| 187 | + // router construction below. | |
| 188 | + unsafe { | |
| 189 | + std::env::set_var("FEDIVERSE_DOMAIN", "csrf-seam.test"); | |
| 190 | + std::env::set_var("FEDIVERSE_DEBUG_LOCAL_FEDERATION", "false"); | |
| 191 | + std::env::set_var("JWT_SECRET", "0123456789abcdef0123456789abcdef"); | |
| 192 | + } | |
| 193 | + | |
| 194 | + let db = mock_db(); | |
| 195 | + let config = rustwarden_fediverse::FediverseConfig::from_env() | |
| 196 | + .expect("FediverseConfig::from_env with FEDIVERSE_DOMAIN set must succeed"); | |
| 197 | + let auth_config = Arc::new( | |
| 198 | + rustwarden_core::config::auth::AuthConfig::from_env() | |
| 199 | + .expect("AuthConfig::from_env with JWT_SECRET set must succeed"), | |
| 200 | + ); | |
| 201 | + let resolver: Arc<dyn rustwarden_fediverse::resolver::LocalUserResolver> = Arc::new( | |
| 202 | + rustwarden_fediverse::resolver::SeaOrmLocalUserResolver::new(db.clone()), | |
| 203 | + ); | |
| 204 | + let verifier: Arc< | |
| 205 | + dyn rustwarden_fediverse::services::fediverse_signal_service::SignatureVerifier, | |
| 206 | + > = Arc::new( | |
| 207 | + rustwarden_fediverse::services::fediverse_signal_service::AlwaysAcceptVerifier::with_resolver( | |
| 208 | + db.clone(), | |
| 209 | + ), | |
| 210 | + ); | |
| 211 | + | |
| 212 | + let machine_router = | |
| 213 | + rustwarden_fediverse::routes::router_with_auth(config, resolver, verifier, auth_config); | |
| 214 | + | |
| 215 | + // No CSRF layer here: machine family. Global session layer mirrors prod. | |
| 216 | + machine_router.with_state(db).layer(session_layer()) | |
| 217 | +} | |
| 218 | + | |
| 219 | +// --------------------------------------------------------------------------- | |
| 220 | +// T10c-inbox — the inbox is machine family: never 403 for a CSRF reason. | |
| 221 | +// --------------------------------------------------------------------------- | |
| 222 | + | |
| 223 | +/// `POST /inbox` with no CSRF token is **never** rejected for a CSRF reason. It | |
| 224 | +/// may legitimately be rejected for a missing/invalid HTTP signature (e.g. 400 | |
| 225 | +/// / 401), but the machine sub-router carries no CSRF layer, so a 403 *for CSRF* | |
| 226 | +/// is structurally impossible. We assert the status is not the CSRF 403 the | |
| 227 | +/// browser layer would produce on an unsigned mutating POST. | |
| 228 | +#[tokio::test] | |
| 229 | +#[serial] | |
| 230 | +async fn t10c_inbox_post_without_csrf_token_is_not_csrf_403() { | |
| 231 | + let app = machine_app_with_inbox(); | |
| 232 | + let req = axum::http::Request::builder() | |
| 233 | + .method(Method::POST) | |
| 234 | + .uri("/inbox") | |
| 235 | + .header(header::CONTENT_TYPE, "application/activity+json") | |
| 236 | + .body(Body::from("{}")) | |
| 237 | + .unwrap(); | |
| 238 | + let resp = app.oneshot(req).await.unwrap(); | |
| 239 | + // The inbox may reject for signature reasons, but never with the CSRF 403. | |
| 240 | + // (Were the inbox mistakenly on the browser channel, an unsigned POST with | |
| 241 | + // no token would be a 403 here.) Documenting the distinction: a 403 *would* | |
| 242 | + // be acceptable only if it came from signature policy, but | |
| 243 | + // `AlwaysAcceptVerifier` accepts any signature, so any 403 observed here | |
| 244 | + // could only originate from a CSRF layer — which must not exist. | |
| 245 | + assert_ne!( | |
| 246 | + resp.status(), | |
| 247 | + StatusCode::FORBIDDEN, | |
| 248 | + "inbox is machine family; it must never be 403 (no CSRF layer, signatures always accepted)" | |
| 249 | + ); | |
| 250 | +} | |
| 251 | + | |
| 252 | +// --------------------------------------------------------------------------- | |
| 253 | +// T10d (structural) — the machine bundle actually carries `/inbox`. | |
| 254 | +// --------------------------------------------------------------------------- | |
| 255 | + | |
| 256 | +/// The inbox route exists on the machine bundle and is reachable without any | |
| 257 | +/// CSRF token: `POST /inbox` responds with something other than 404. This is | |
| 258 | +/// the structural proof that the inbox lives in the machine family and is | |
| 259 | +/// reachable on the CSRF-free channel (Annexe B requalified T10d). | |
| 260 | +#[tokio::test] | |
| 261 | +#[serial] | |
| 262 | +async fn t10d_machine_bundle_exposes_inbox_route() { | |
| 263 | + let app = machine_app_with_inbox(); | |
| 264 | + let req = axum::http::Request::builder() | |
| 265 | + .method(Method::POST) | |
| 266 | + .uri("/inbox") | |
| 267 | + .header(header::CONTENT_TYPE, "application/activity+json") | |
| 268 | + .body(Body::from("{}")) | |
| 269 | + .unwrap(); | |
| 270 | + let resp = app.oneshot(req).await.unwrap(); | |
| 271 | + assert_ne!( | |
| 272 | + resp.status(), | |
| 273 | + StatusCode::NOT_FOUND, | |
| 274 | + "the machine bundle must expose POST /inbox (route present, reachable without CSRF)" | |
| 275 | + ); | |
| 276 | +} |
M
crates/rustwarden-ui/src/lib.rs
+10
-0
@@ -18,7 +18,17 @@ use axum::Router;
| 18 | 18 | use sea_orm::DatabaseConnection; |
| 19 | 19 | use tower_http::services::ServeDir; |
| 20 | 20 | |
| 21 | +pub use routes::{api_routes, browser_routes}; | |
| 22 | + | |
| 21 | 23 | /// Returns the default UI routes for Rustwarden (login, profile, admin, etc.). |
| 24 | +/// | |
| 25 | +/// This combines the browser SSR routes and the `/api/v1/*` machine API in a | |
| 26 | +/// single router. Because mounting it via `with_ui` would put `/api/v1/*` | |
| 27 | +/// behind the CSRF layer, prefer mounting [`browser_routes`] via `with_ui` | |
| 28 | +/// and [`api_routes`] via `merge_machine_routes` instead. | |
| 29 | +#[deprecated( | |
| 30 | + note = "Mount browser_routes() via with_ui() and api_routes() via merge_machine_routes(); see CSRF plan" | |
| 31 | +)] | |
| 22 | 32 | pub fn default_routes() -> Router<DatabaseConnection> { |
| 23 | 33 | routes::create_router() |
| 24 | 34 | } |
M
crates/rustwarden-ui/src/routes.rs
+67
-42
@@ -11,40 +11,16 @@ use crate::handlers::{
| 11 | 11 | }; |
| 12 | 12 | use rustwarden_core::auth::handlers as auth_handlers; |
| 13 | 13 | |
| 14 | -/// Create the main application router for Rustwarden | |
| 15 | -pub fn create_router() -> Router<DatabaseConnection> { | |
| 16 | - let auth_routes = Router::new() | |
| 17 | - .route("/api/v1/auth/login", post(auth_handlers::login)) | |
| 18 | - .route("/api/v1/auth/register", post(auth_handlers::register)) | |
| 19 | - .route("/api/v1/auth/refresh", post(auth_handlers::refresh_token)) | |
| 20 | - .route("/api/v1/auth/logout", post(auth_handlers::logout)) | |
| 21 | - .route("/api/v1/auth/me", get(auth_handlers::get_current_user)) | |
| 22 | - .route( | |
| 23 | - "/api/v1/auth/forgot-password", | |
| 24 | - post(auth_handlers::forgot_password), | |
| 25 | - ) | |
| 26 | - .route( | |
| 27 | - "/api/v1/auth/reset-password", | |
| 28 | - post(auth_handlers::reset_password), | |
| 29 | - ) | |
| 30 | - .route( | |
| 31 | - "/api/v1/auth/verify-email/{token}", | |
| 32 | - get(auth_handlers::verify_email), | |
| 33 | - ) | |
| 34 | - .route( | |
| 35 | - "/api/v1/auth/resend-verification", | |
| 36 | - post(auth_handlers::resend_verification), | |
| 37 | - ) | |
| 38 | - .route( | |
| 39 | - "/api/v1/auth/resend-verification/by-username", | |
| 40 | - post(auth_handlers::resend_verification_by_username), | |
| 41 | - ) | |
| 42 | - .route("/api/v1/auth/2fa/verify", post(auth_handlers::verify_2fa)); | |
| 43 | - | |
| 44 | - let user_routes = Router::new() | |
| 45 | - .route("/api/v1/users", post(user_handler::create_user)) | |
| 46 | - .route("/api/v1/users", get(user_handler::list_users)); | |
| 47 | - | |
| 14 | +/// Build the **browser** family of UI routes (SSR, session-cookie driven). | |
| 15 | +/// | |
| 16 | +/// These routes are meant to be mounted on the CSRF-protected channel of the | |
| 17 | +/// application builder (`with_ui` / `merge_browser_routes`). It covers the | |
| 18 | +/// landing page, public pages (login, password reset…), authenticated UI | |
| 19 | +/// pages (profile, settings), the admin panel, and the static asset service. | |
| 20 | +/// | |
| 21 | +/// The machine API (`/api/v1/*`) is intentionally *not* included here; mount | |
| 22 | +/// it separately via [`api_routes`] on the machine channel. | |
| 23 | +pub fn browser_routes() -> Router<DatabaseConnection> { | |
| 48 | 24 | let public_routes = Router::new() |
| 49 | 25 | .route("/login", get(login_handler::login_page)) |
| 50 | 26 | .route( |
@@ -121,12 +97,11 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 121 | 97 | .route("/admin/roles/{id}", delete(admin_handler::delete_role)) |
| 122 | 98 | .route("/admin/settings", get(admin_handler::get_app_settings)) |
| 123 | 99 | .route("/admin/settings", put(admin_handler::update_app_settings)) |
| 124 | - .route("/admin/errors", get(admin_handler::admin_errors)); | |
| 125 | - | |
| 126 | - let admin_routes = admin_routes.route( | |
| 127 | - "/admin/users/{id}/reset-totp", | |
| 128 | - post(admin_handler::reset_user_totp), | |
| 129 | - ); | |
| 100 | + .route("/admin/errors", get(admin_handler::admin_errors)) | |
| 101 | + .route( | |
| 102 | + "/admin/users/{id}/reset-totp", | |
| 103 | + post(admin_handler::reset_user_totp), | |
| 104 | + ); | |
| 130 | 105 | |
| 131 | 106 | let static_dir = |
| 132 | 107 | std::env::var("RUSTWARDEN_STATIC_DIR").unwrap_or_else(|_| "static".to_string()); |
@@ -137,13 +112,63 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 137 | 112 | Router::new() |
| 138 | 113 | .route("/", get(root_handler::root_redirect)) |
| 139 | 114 | .merge(public_routes) |
| 140 | - .merge(auth_routes) | |
| 141 | - .merge(user_routes) | |
| 142 | 115 | .merge(ui_routes) |
| 143 | 116 | .merge(admin_routes) |
| 144 | 117 | .nest_service("/static", static_service) |
| 145 | 118 | } |
| 146 | 119 | |
| 120 | +/// Build the **machine** family of API routes (`/api/v1/*`). | |
| 121 | +/// | |
| 122 | +/// These are JSON endpoints authenticated by `Authorization: Bearer` (or | |
| 123 | +/// pre-session for login/register) and are *not* CSRF-vulnerable. Mount them | |
| 124 | +/// on the machine channel of the application builder | |
| 125 | +/// (`merge_machine_routes`), never behind the CSRF layer. | |
| 126 | +pub fn api_routes() -> Router<DatabaseConnection> { | |
| 127 | + let auth_routes = Router::new() | |
| 128 | + .route("/api/v1/auth/login", post(auth_handlers::login)) | |
| 129 | + .route("/api/v1/auth/register", post(auth_handlers::register)) | |
| 130 | + .route("/api/v1/auth/refresh", post(auth_handlers::refresh_token)) | |
| 131 | + .route("/api/v1/auth/logout", post(auth_handlers::logout)) | |
| 132 | + .route("/api/v1/auth/me", get(auth_handlers::get_current_user)) | |
| 133 | + .route( | |
| 134 | + "/api/v1/auth/forgot-password", | |
| 135 | + post(auth_handlers::forgot_password), | |
| 136 | + ) | |
| 137 | + .route( | |
| 138 | + "/api/v1/auth/reset-password", | |
| 139 | + post(auth_handlers::reset_password), | |
| 140 | + ) | |
| 141 | + .route( | |
| 142 | + "/api/v1/auth/verify-email/{token}", | |
| 143 | + get(auth_handlers::verify_email), | |
| 144 | + ) | |
| 145 | + .route( | |
| 146 | + "/api/v1/auth/resend-verification", | |
| 147 | + post(auth_handlers::resend_verification), | |
| 148 | + ) | |
| 149 | + .route( | |
| 150 | + "/api/v1/auth/resend-verification/by-username", | |
| 151 | + post(auth_handlers::resend_verification_by_username), | |
| 152 | + ) | |
| 153 | + .route("/api/v1/auth/2fa/verify", post(auth_handlers::verify_2fa)); | |
| 154 | + | |
| 155 | + let user_routes = Router::new() | |
| 156 | + .route("/api/v1/users", post(user_handler::create_user)) | |
| 157 | + .route("/api/v1/users", get(user_handler::list_users)); | |
| 158 | + | |
| 159 | + Router::new().merge(auth_routes).merge(user_routes) | |
| 160 | +} | |
| 161 | + | |
| 162 | +/// Create the main application router for Rustwarden. | |
| 163 | +/// | |
| 164 | +/// Combines [`browser_routes`] and [`api_routes`] into a single router. This | |
| 165 | +/// puts `/api/v1/*` and the SSR UI in one tree; prefer mounting the two | |
| 166 | +/// families on their dedicated channels (`with_ui` + `merge_machine_routes`) | |
| 167 | +/// so the CSRF layer covers the browser family only. | |
| 168 | +pub fn create_router() -> Router<DatabaseConnection> { | |
| 169 | + browser_routes().merge(api_routes()) | |
| 170 | +} | |
| 171 | + | |
| 147 | 172 | /// Returns a minimal router with only the health-check route, used in tests. |
| 148 | 173 | #[allow(dead_code)] |
| 149 | 174 | pub fn create_test_router() -> Router { |
M
crates/rustwarden-ui/templates/base.html
+3
-1
@@ -20,6 +20,8 @@
| 20 | 20 | <meta charset="UTF-8"> |
| 21 | 21 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 22 | 22 | <meta name="description" content="{% if app_description != "" %}{{ app_description }}{% else %}{{ app_name }}{% endif %}"> |
| 23 | + <!-- CSRF token exposed for JS clients; mirrored on <body hx-headers> for HTMX. --> | |
| 24 | + <meta name="csrf-token" content="{{ csrf_token }}"> | |
| 23 | 25 | <title>{% block title %}{{ page_title }} - {{ app_name }}{% endblock %}</title> |
| 24 | 26 | |
| 25 | 27 | <!-- Favicon --> |
@@ -41,7 +43,7 @@
| 41 | 43 | |
| 42 | 44 | {% block head_extra %}{% endblock %} |
| 43 | 45 | </head> |
| 44 | -<body class="bg-base-100 min-h-screen flex flex-col"> | |
| 46 | +<body class="bg-base-100 min-h-screen flex flex-col" hx-headers='{"X-CSRF-Token": "{{ csrf_token }}"}'> | |
| 45 | 47 | <!-- Navbar (fixe en haut) - Include in child templates that need it --> |
| 46 | 48 | {% block navbar %}{% endblock %} |
| 47 | 49 |
A
docs/plans/2026-06-04-csrf-protection-structurelle.md
+339
-0
@@ -0,0 +1,339 @@
| 1 | +# Plan — Protection CSRF structurelle pour Rustwarden | |
| 2 | + | |
| 3 | +> Statut : **Lot 1 + Lot 2 livrés** · 2026-06-04 · branche `fedivers` | |
| 4 | +> Périmètre : `rustwarden-core` (middleware + câblage + **API du builder**), `rustwarden-ui`, **partition des routeurs de `rustwarden-fediverse` / `rustwarden-fediverse-ui`**, vérification de non-régression `rustwarden-fediverse-guard`. | |
| 5 | + | |
| 6 | +--- | |
| 7 | + | |
| 8 | +## 0. Décisions (tranchées le 2026-06-04) | |
| 9 | + | |
| 10 | +> Le câblage CSRF est facile ; **la séparation des responsabilités à la frontière entre crates est le vrai sujet** (cf. §1bis). | |
| 11 | + | |
| 12 | +| ID | Décision | Choix retenu | | |
| 13 | +|---|---|---| | |
| 14 | +| **D1** | Frontière browser/machine à travers la limite de crate (§4.2) | **C — Hybride** : canaux explicites `merge_browser_routes`/`merge_machine_routes` **+** marqueur `CsrfExempt` sur l'inbox. Topologie lisible **et** fédération incassable au mauvais branchement. | | |
| 15 | +| **D2** | Défaut du `merge_routes()` existant (§4.3) | **A — Browser/CSRF par défaut** : `merge_routes()` protège ; routes machine → `merge_machine_routes()`. Fail-safe (B1). Migration à documenter (piège n°12). | | |
| 16 | +| **D3** | `validate_csrf_token` par handler fediverse-ui (Phase 3) | **a — Garder** comme defense-in-depth documentée. | | |
| 17 | +| **D4** | Périmètre / découpage | **B — Incrémental** : **Lot 1** core + `rustwarden-ui` + API canaux + tests browser/machine/secure-by-default (T1–T10b). **Lot 2** partition fediverse + marqueur inbox + T10c/T10d + T15. Entre les deux, la fédération reste couverte par ses `validate_csrf_token` existants. | | |
| 18 | + | |
| 19 | +**Découpage en lots (D4=B) :** | |
| 20 | +- **Lot 1** (ce plan, exécutable immédiatement) : Phase 0 (hors A0.4 déjà tranchée), Phase 1 (A1.1–A1.5, canaux + marqueur défini mais inbox traité en lot 2), Phase 2, Phase 4. Tests T1–T10b, T11–T14. | |
| 21 | +- **Lot 2** (fediverse) — ✅ **livré 2026-06-04** : Phase 3 (A3.0–A3.2). Exemption de l'inbox **purement topologique** (pas de marqueur runtime, cf. Annexe B) : `fediverse_router` (inbox + observer) → `merge_machine_routes` ; `rustwarden-fediverse-ui::default_routes()` et `rustwarden-fediverse-guard::ui::default_routes()` → `merge_browser_routes`. Rebranchement de [`main.rs`](../../app/src/main.rs) (guard UI séparée du `fediverse_router`) et des chaînes de [`tools/rustwarden-cli/src/templates.rs`](../../tools/rustwarden-cli/src/templates.rs). Tests T10c/T10d ajoutés ([`csrf_seam.rs`](../../crates/rustwarden-fediverse-ui/tests/csrf_seam.rs)) + T15 (suites fediverse-ui/guard vertes). | |
| 22 | + | |
| 23 | +> ⚠️ Conséquence D4=B : tant que le Lot 2 n'est pas livré, **ne pas** basculer l'inbox vers le canal browser. Le défaut D2=A ne s'applique qu'aux nouvelles routes tierces ; le câblage fediverse actuel (`merge_routes(fediverse_router)`) doit migrer vers `merge_machine_routes()` **dès le Lot 1** pour que l'inbox ne prenne pas le CSRF — c'est le seul point fediverse à traiter en Lot 1 (une ligne dans `main.rs`), le reste de la partition attend le Lot 2. | |
| 24 | + | |
| 25 | +--- | |
| 26 | + | |
| 27 | +## 1. Contexte & constat (audit du 2026-06-04) | |
| 28 | + | |
| 29 | +Le mécanisme CSRF est **implémenté et correctement conçu** mais **n'est réellement appliqué que dans les crates fediverse**. Dans `rustwarden-ui` (login, profil, settings, admin), les tokens sont générés et injectés dans les templates mais **jamais validés** — la protection y est cosmétique. | |
| 30 | + | |
| 31 | +Faits établis (vérifiés par lecture directe du code) : | |
| 32 | + | |
| 33 | +| Fait | Preuve | | |
| 34 | +|---|---| | |
| 35 | +| Le moteur CSRF est solide | [`csrf.rs`](../../crates/rustwarden-core/src/middleware/csrf.rs) : token 32 o RNG → 64 hex en session, comparaison constant-time (`subtle::ConstantTimeEq`), exemption GET/HEAD/OPTIONS, bypass `Authorization: Bearer` | | |
| 36 | +| Le middleware global n'est **jamais câblé** | `csrf_protection` / `csrf_protection_with_config` n'apparaissent que dans des commentaires rustdoc ; **0 appel** dans [`app.rs`](../../crates/rustwarden-core/src/app.rs) | | |
| 37 | +| La validation par handler n'existe que côté fediverse | `validate_csrf_token` : 10× dans `rustwarden-fediverse-ui`, 1× dans `rustwarden-fediverse-guard`, **0× dans `rustwarden-ui`** | | |
| 38 | +| Dans `rustwarden-ui`, les `csrf_token` sont des champs de structs `…Template` (sortie), jamais de structs `Form` (entrée) | aucun handler ne lit le token reçu ; ex. `update_profile` renvoie le succès sans contrôle | | |
| 39 | +| L'infra CORS est déjà à moitié prête | `x-csrf-token` est dans `allow_headers` ([`app.rs:516`](../../crates/rustwarden-core/src/app.rs#L516), [`app.rs:543`](../../crates/rustwarden-core/src/app.rs#L543)) avec `allow_credentials(true)` | | |
| 40 | +| **Il n'existe qu'UN routeur**, pas deux | [`app.rs:445-453`](../../crates/rustwarden-core/src/app.rs#L445-L453) : `Router::new()` fusionne `with_ui()`, **tous** les `merge_routes()` du consommateur, et les routes OAuth, **puis** applique le `ServiceBuilder` à l'ensemble. Aucune notion browser/machine aujourd'hui. | | |
| 41 | +| La fédération entre par le **même canal opaque** que le code tiers | [`main.rs:125`](../../app/src/main.rs#L125) `.merge_routes(fediverse_router)` (contient **`POST /inbox`** = machine) et [`main.rs:140`](../../app/src/main.rs#L140) `.merge_routes(rustwarden_fediverse_ui::default_routes())` (contient **consent/invite/login** = browser) passent tous deux par `merge_routes()`. Core **ne peut pas** distinguer machine de browser dans ce blob. | | |
| 42 | + | |
| 43 | +**Impact.** Toutes les actions mutantes de l'UI cœur sont vulnérables au CSRF : changement de profil/email, changement de mot de passe (`PUT /settings/password`), révocation de session, enable/disable TOTP, et surtout l'admin (création/suppression d'utilisateurs et de rôles, reset password, app settings). Un site tiers peut forger ces requêtes contre un admin authentifié par cookie de session. | |
| 44 | + | |
| 45 | +--- | |
| 46 | + | |
| 47 | +## 1bis. Le point aveugle : la frontière traverse une limite de crate | |
| 48 | + | |
| 49 | +> **C'est la question structurante du lot.** Le plan « split de routeurs » revendique une garantie *topologique* (« impossible à oublier, impossible de casser la fédération »). Cette garantie **ne survit pas à la couture inter-crate** telle que l'API est aujourd'hui. | |
| 50 | + | |
| 51 | +`app.rs` reçoit `custom_routes: Vec<Router<DatabaseConnection>>` — un blob **opaque**. Il ne sait pas lequel contient `/inbox` (machine, signature HTTP, **aucun token CSRF possible**) ni lequel contient le formulaire de consentement (browser, **doit avoir le CSRF**). La séparation des responsabilités tient donc **seulement pour les routes que core construit lui-même** (`ui_fn`, `oauth_routes`) ; elle s'effondre dès qu'un routeur franchit la frontière de crate. | |
| 52 | + | |
| 53 | +**Correction du contrat « avec ou sans fédération » :** | |
| 54 | + | |
| 55 | +| Scénario | Verdict sous le plan actuel | Ce qu'il manque | | |
| 56 | +|---|---|---| | |
| 57 | +| **Sans fédération** (core + `rustwarden-ui` + routes tierces) | Correct *uniquement si* le défaut de `merge_routes()` est tranché **fail-safe (browser/CSRF)** ET qu'un canal machine explicite existe pour les API/webhooks tiers. | Ni le split, ni le canal machine n'existent. Un consommateur qui ajoute une API JSON par cookie ou un webhook serait soit cassé (413/403), soit non protégé. | | |
| 58 | +| **Avec fédération** | `/inbox` arrive via `merge_routes()`. Si le défaut = browser/CSRF, **l'inbox prend le CSRF → fédération morte** (piège n°1, par la couture, pas par un global naïf). | Le test T7 suppose que core *sait* que l'inbox est machine. Il ne le sait pas tant que la famille n'est pas déclarée explicitement à la frontière. | | |
| 59 | + | |
| 60 | +**Conclusion : la sécurité n'est PAS correcte « avec ou sans fédération » tant que la frontière browser/machine n'est pas rendue explicite dans l'API du builder, ou que l'inbox ne s'auto-exempte pas indépendamment du routage.** Trois stratégies sont possibles (cf. §4, décision D1). | |
| 61 | + | |
| 62 | +> Précédent encourageant : core expose déjà `register_protected_prefix()` ([`app.rs:249`](../../crates/rustwarden-core/src/app.rs#L249)) et `register_revocation_checker()` pour que les crates compagnonnes déclarent des métadonnées de routage. Un couple `merge_browser_routes` / `merge_machine_routes` s'inscrit exactement dans cet idiome. | |
| 63 | + | |
| 64 | +--- | |
| 65 | + | |
| 66 | +## 2. Besoins | |
| 67 | + | |
| 68 | +- **B1 — Sécurité structurelle, pas disciplinaire.** Rustwarden est un *framework* : un développeur tiers qui ajoute une route mutante doit hériter de la protection **sans avoir à y penser**. Le mode d'échec par défaut doit être *fail-safe* (rejet visible), jamais *fail-open* (trou silencieux). Le pattern « validation par handler » a déjà échoué dans ce dépôt même → on ne le reproduit pas pour le cœur. | |
| 69 | +- **B2 — Ne rien casser de fonctionnel.** En particulier l'inbox ActivityPub (`POST /inbox`, authentifiée par **signature HTTP**, sans token CSRF possible), les callbacks OAuth, les webhooks et l'API JSON Bearer. | |
| 70 | +- **B3 — Couvrir les deux profils de front.** SPA/HTMX (token via header `X-CSRF-Token`) **et** SSR sans JS (token via champ de formulaire `csrf_token`). | |
| 71 | +- **B4 — Préserver l'existant fediverse.** Les crates fediverse valident déjà par handler ; il faut décider explicitement entre redondance assumée (defense-in-depth) et nettoyage, sans régression. | |
| 72 | +- **B5 — Auditabilité.** Prouver « tout est protégé » doit se lire à **un seul endroit** (la topologie du routeur), pas en relisant N handlers. | |
| 73 | + | |
| 74 | +--- | |
| 75 | + | |
| 76 | +## 3. Objectifs (résultats mesurables) | |
| 77 | + | |
| 78 | +- **O1.** Toute requête `POST/PUT/DELETE/PATCH` portée par un cookie de session sur le routeur « browser » est rejetée **403** si le token CSRF est absent ou invalide. | |
| 79 | +- **O2.** L'inbox ActivityPub et les endpoints machine (API JSON, webhooks, callbacks OAuth) **ne sont pas** soumis au CSRF et continuent de répondre normalement (pas de 403 parasite). | |
| 80 | +- **O3.** Une route mutante ajoutée par un consommateur dans le périmètre « browser » est protégée **par défaut**, sans code supplémentaire dans son handler. | |
| 81 | +- **O4.** Les deux canaux de token fonctionnent : header `X-CSRF-Token` (SPA/HTMX) et champ `csrf_token` en `application/x-www-form-urlencoded` (SSR). | |
| 82 | +- **O5.** Zéro régression : suite de tests `cargo test` verte sur tout le workspace ; `cargo clippy -D warnings` propre ; les flux fediverse restent fonctionnels. | |
| 83 | + | |
| 84 | +--- | |
| 85 | + | |
| 86 | +## 4. Décision d'architecture | |
| 87 | + | |
| 88 | +### 4.1 Mécanique du split (intra-core) — tranchée | |
| 89 | + | |
| 90 | +**Split de routeurs** (option retenue parmi les trois étudiées : split / `route_layer` / skip-par-path). | |
| 91 | + | |
| 92 | +| Stratégie | Verdict | | |
| 93 | +|---|---| | |
| 94 | +| **Split de routeurs** ✅ | Exclusion **topologique** : le routeur « machine » ne porte structurellement pas le layer CSRF → impossible à oublier, impossible de casser la fédération. Fail-safe pour le code tiers du côté browser. | | |
| 95 | +| `route_layer` ciblé | Le consommateur tiers doit penser à l'appliquer sur *ses* sous-routeurs → re-fail-open pour le code externe. Rejeté pour B1. | | |
| 96 | +| Skip par préfixe dans le middleware | Liste codée en dur, fragile ; un préfixe trop large rouvre le trou silencieusement. Rejeté pour B1/B5. | | |
| 97 | + | |
| 98 | +**Conséquence concrète :** séparer la construction du routeur en deux familles dont l'une seulement reçoit le layer CSRF. | |
| 99 | + | |
| 100 | +**Montage concret du layer (vérifié vs sémantique `Router::merge`).** Les layers communs (CORS, security_headers, AdminService, AuthService, `session_layer`, locale, Extensions) restent **globaux** sur le routeur fusionné. Seul le CSRF est appliqué au **sous-routeur browser avant fusion** : | |
| 101 | +```rust | |
| 102 | +let browser = browser_router.layer(from_fn_with_state(state, csrf_protection_with_config)); // CSRF interne, browser only | |
| 103 | +let machine = machine_router; // pas de CSRF | |
| 104 | +let app = browser.merge(machine) | |
| 105 | + .with_state(db) | |
| 106 | + .layer(/* ServiceBuilder commun : cors … session_layer … locale … */); | |
| 107 | +``` | |
| 108 | +Ordre d'exécution résultant : `… session_layer (global) → dispatch de route → CSRF (browser uniquement) → handler`. La session tourne donc **avant** le CSRF (pré-requis de l'extracteur `Session`) et le CSRF ne touche **jamais** les routes machine. Les layers posés sur un sous-routeur avant `merge` sont préservés par la fusion (sémantique axum confirmée). | |
| 109 | + | |
| 110 | +### 4.2 La couture inter-crate (D1) — **DÉCISION REQUISE** | |
| 111 | + | |
| 112 | +Le split §4.1 ne suffit pas seul (cf. §1bis) : `app.rs` doit *savoir* quelle famille reçoit chaque routeur fusionné. Trois stratégies, non mutuellement exclusives : | |
| 113 | + | |
| 114 | +| # | Stratégie | Garantie | Coût / risque | | |
| 115 | +|---|---|---|---| | |
| 116 | +| **A** | **Deux canaux explicites** : `merge_browser_routes(Router)` (→ CSRF) et `merge_machine_routes(Router)` (→ sans CSRF). Les crates fediverse exposent leurs bundles déjà partitionnés ; le consommateur branche chacun au bon canal. | Topologique côté core ; le seul point de discipline restant est **un choix par bundle** (pas par handler) à la frontière. | Légère évolution d'API ; le consommateur peut encore se tromper de canal (mais 1 décision visible, pas N). | | |
| 117 | +| **B** | **Auto-exemption par marqueur** : routeur unique + CSRF global, mais les routes machine portent une extension `CsrfExempt` posée *par la crate qui les définit* (l'inbox se protège lui-même). Le middleware CSRF saute si le marqueur est présent. | L'inbox est exempté **quelle que soit** la manière dont le consommateur le monte → la fédération ne peut pas être cassée par erreur de câblage. | Réintroduit un opt-out par route (proche du skip-par-path rejeté), mais **scopé par la crate propriétaire**, donc bien plus sûr qu'une liste de préfixes centrale. | | |
| 118 | +| **C** | **Hybride (recommandé)** : A **+** auto-exemption de l'inbox (ceinture-bretelles). Canaux explicites pour la lisibilité d'audit (B5), marqueur sur l'inbox pour qu'un mauvais branchement ne tue jamais la fédération. | La plus robuste : topologie claire **et** sûreté de l'inbox indépendante du consommateur. | Surface d'implémentation la plus large (les deux mécanismes). | | |
| 119 | + | |
| 120 | +> Quelle que soit l'option, **`with_ui()` reste browser** et **`oauth_routes` reste machine** (tranché par core). | |
| 121 | + | |
| 122 | +### 4.3 Défaut du `merge_routes()` existant (D2) — **DÉCISION REQUISE** | |
| 123 | + | |
| 124 | +`merge_routes()` est déjà utilisé (fédération, code tiers). Son comportement par défaut détermine la sûreté du code tiers *sans fédération* : | |
| 125 | + | |
| 126 | +| # | Choix | Conformité B1 | Compat | | |
| 127 | +|---|---|---|---| | |
| 128 | +| **A** (recommandé) | `merge_routes()` devient **browser/CSRF par défaut** ; les routes machine doivent migrer vers `merge_machine_routes()`. | ✅ fail-safe pour le tiers | Rupture comportementale : une route machine montée par l'ancien `merge_routes` prend le CSRF (visible, fail-closed). | | |
| 129 | +| **B** | `merge_routes()` reste **sans CSRF** (compat ascendante). | ❌ fail-open pour le tiers (re-piège B1) | Aucune rupture. | | |
| 130 | +| **C** | **Déprécier** `merge_routes()` ; forcer le choix `merge_browser_routes` / `merge_machine_routes`. | ✅ pas de défaut implicite | Rupture d'API contrôlée (le consommateur doit choisir). | | |
| 131 | + | |
| 132 | +### 4.4 Placement du layer (vérifié vs doc officielle Axum/Tower) | |
| 133 | + | |
| 134 | +``` | |
| 135 | +Router browser (UI, session-cookie) Router machine (inbox AP, API JSON, webhooks, OAuth cb) | |
| 136 | + ── CSRF appliqué ── PAS de CSRF (HTTP signatures / Bearer / public) | |
| 137 | + │ │ | |
| 138 | + └──────────────── Router::merge ──────────────┘ | |
| 139 | +``` | |
| 140 | + | |
| 141 | +Rustwarden utilise un unique `ServiceBuilder` ([`app.rs:559-578`](../../crates/rustwarden-core/src/app.rs#L559-L578)), qui s'exécute **de haut en bas** (déclaration = exécution ; doc Tower : *« Layers added first will be called with the request first »*). Le CSRF extrait la session via l'extracteur `Session`, lequel a besoin que `session_layer` ait déjà tourné → **le CSRF doit s'exécuter après `session_layer`** (cf. montage §4.1 : CSRF interne au sous-routeur browser, `session_layer` global au-dessus). | |
| 142 | + | |
| 143 | +Ordre d'exécution cible sur le routeur browser : | |
| 144 | +``` | |
| 145 | +CORS → security_headers → AdminService → AuthService → session_layer → CSRF → locale → handler | |
| 146 | +``` | |
| 147 | + | |
| 148 | +**Implémentation maison conservée.** Le `csrf.rs` actuel implémente le *Synchronizer Token Pattern*, conforme aux trois crates de référence de l'écosystème (`axum-tower-sessions-csrf`, `axum-csrf-sync-pattern`, `axum_csrf`). Aucune dépendance externe à ajouter. On active simplement `csrf_protection_with_config` avec `accept_form_body = true` pour couvrir B3. | |
| 149 | + | |
| 150 | +--- | |
| 151 | + | |
| 152 | +## 5. Actions à mener | |
| 153 | + | |
| 154 | +### Phase 0 — Cartographie des routes + décisions de couture (préalable bloquant) | |
| 155 | +- [ ] **A0.1** Inventorier toutes les routes par famille : « browser » (UI rendue, session-cookie) vs « machine » (inbox AP, `/api/v1/auth/*`, callbacks OAuth, webhooks, `.well-known`, assets statiques). Produire une table de référence dans ce plan (annexe A) — c'est elle qui pilote le split. Inclure **l'origine** de chaque route (core / `rustwarden-ui` / `rustwarden-fediverse` / `rustwarden-fediverse-ui` / tiers). | |
| 156 | +- [ ] **A0.2** Confirmer le point d'entrée exact où le routeur principal est assemblé ([`app.rs:445-453`](../../crates/rustwarden-core/src/app.rs#L445-L453), routeur **unique** aujourd'hui) et où injecter le split. | |
| 157 | +- [ ] **A0.3** Lister les routes mutantes « machine » légitimement sans CSRF et **justifier chacune** (inbox = signature HTTP, API = Bearer, etc.). Toute route qui ne rentre dans aucune justification → bascule côté browser. | |
| 158 | +- [ ] **A0.4 — ⚖️ Acter D1 (couture inter-crate, §4.2)** et **D2 (défaut `merge_routes`, §4.3)**. Ces deux décisions conditionnent toute la Phase 1. Sans elles, le split ne tient pas à la frontière fediverse/tiers. | |
| 159 | + | |
| 160 | +### Phase 1 — Câblage : split du routeur + canaux d'API | |
| 161 | +- [ ] **A1.1** Construire le `State` du middleware : `(DatabaseConnection, Arc<CsrfProtectionConfig>)` avec `accept_form_body = true`, `max_form_body_bytes = DEFAULT_MAX_FORM_BODY_BYTES`. | |
| 162 | +- [ ] **A1.2** Refactorer le build de [`app.rs`](../../crates/rustwarden-core/src/app.rs) en **deux sous-routeurs** (`browser_router`, `machine_router`). `with_ui()` → browser ; `oauth_routes` → machine. Appliquer le CSRF **au seul `browser_router` avant `merge`** (montage §4.1), puis appliquer le `ServiceBuilder` commun (CORS … `session_layer` … locale) à l'ensemble fusionné. | |
| 163 | +- [ ] **A1.3 — selon D1.** Exposer les canaux retenus : (A/C) `merge_browser_routes()` + `merge_machine_routes()` sur `RustwardenBuilder` ; (B/C) le marqueur d'exemption `CsrfExempt` lu par le middleware. Adapter le défaut de `merge_routes()` selon **D2**. Mettre à jour [`app/src/main.rs`](../../app/src/main.rs) : `fediverse_router` (inbox) → canal machine ; `fediverse_ui::default_routes()` → canal browser. | |
| 164 | +- [ ] **A1.4** Vérifier que CORS reste extérieur au CSRF (preflight OPTIONS non bloqués) — déjà le cas, à confirmer après refactor. | |
| 165 | +- [ ] **A1.5** S'assurer que le split n'a pas cassé l'héritage des layers vers les `fallback`/`nest` (piège axum #3183) : la branche browser doit porter à la fois `session_layer` (global) ET le CSRF (interne). | |
| 166 | + | |
| 167 | +### Phase 2 — Alignement des templates `rustwarden-ui` | |
| 168 | +- [ ] **A2.1** Pour les formulaires SSR (form-submit classique) : garantir un `<input type="hidden" name="csrf_token" value="{{ csrf_token }}">` présent et correct. Combler les manques relevés à l'audit (ex. `settings/password.html`, `resend_verification.html`). | |
| 169 | +- [ ] **A2.2** Pour les appels `fetch()`/HTMX : garantir l'en-tête `X-CSRF-Token: {{ csrf_token }}` (et/ou `hx-headers`). Uniformiser sur **un** nom de champ (`csrf_token`) et **un** header (`X-CSRF-Token`). | |
| 170 | +- [ ] **A2.3** Retirer les tokens posés sur des formulaires `method="GET"` (inutiles, ex. recherche admin users) pour éviter la confusion d'audit. | |
| 171 | +- [ ] **A2.4** Vérifier que chaque handler GET qui rend un formulaire mutant alimente bien `csrf_token` dans son contexte de template (via `csrf_token_helper` / `LayoutContext`). | |
| 172 | + | |
| 173 | +### Phase 3 — Couture fediverse & redondance (B4 / D1 / D3) — ✅ Lot 2 | |
| 174 | +- [x] **A3.0 — selon D1.** Partition déjà acquise par construction : l'inbox (`POST /inbox`) vit dans `rustwarden_fediverse::routes::router_with_auth(...)`, monté en `merge_machine_routes` ; la famille browser de `rustwarden-fediverse-ui` (`default_routes()`) et la guard UI (`rustwarden_fediverse_guard::ui::default_routes()`) sont montées en `merge_browser_routes`. La guard UI a été **séparée** de `fediverse_router` (qui ne porte plus que l'observer + l'inbox) dans [`app/src/main.rs`](../../app/src/main.rs). Réalisation **topologique** (Annexe B) : aucun marqueur runtime, l'inbox ne peut pas porter le CSRF car son routeur n'a pas le layer. | |
| 175 | +- [x] **A3.1 — ⚖️ D3=a.** `validate_csrf_token` par handler **conservés** comme defense-in-depth documentée (fediverse-ui : 10 occurrences ; guard `manual_ban_submit` : 1). Le layer browser global et la validation per-handler coexistent : le layer lit/valide le token dans le body urlencoded bufferisé puis **reconstruit** le body, que le handler ré-extrait via `Form<…>` et re-valide. | |
| 176 | +- [x] **A3.2** Double-lecture du body **vérifiée** : preuve par les tests verts. Le test core T4 prouve la reconstruction du body sur un `Form<…>` aval ; le nouveau test fediverse-ui `csrf_seam::t10c_consent_post_with_valid_form_token_is_not_csrf_rejected` rejoue ce chemin sur le **vrai** routeur consent (token form-body valide → pas de 403). Fichier : [`crates/rustwarden-fediverse-ui/tests/csrf_seam.rs`](../../crates/rustwarden-fediverse-ui/tests/csrf_seam.rs). | |
| 177 | + | |
| 178 | +### Phase 4 — Documentation | |
| 179 | +- [ ] **A4.1** Corriger/compléter la rustdoc de [`middleware/mod.rs`](../../crates/rustwarden-core/src/middleware/mod.rs) qui présente `csrf_protection` comme actif alors qu'il ne l'était pas — refléter le câblage réel. | |
| 180 | +- [ ] **A4.2** Ajouter un guide consommateur (`docs/`) : « comment ajouter une route mutante protégée », « comment déclarer une route machine exemptée », le contrat des deux canaux de token. Référencer [`docs/auth-middleware-layering.md`](../../docs/auth-middleware-layering.md) pour l'ordre des layers. | |
| 181 | + | |
| 182 | +--- | |
| 183 | + | |
| 184 | +## 6. Pièges à éviter | |
| 185 | + | |
| 186 | +1. **Inbox ActivityPub cassée** — *le piège n°1*. Un CSRF global naïf rejette `POST /inbox` (signature HTTP, pas de token possible) → fédération morte. **Garde-fou : le split de routeurs, vérifié par un test dédié** (cf. T7). | |
| 187 | +2. **Session vide / panique** — placer le CSRF **au-dessus** de `session_layer` dans le `ServiceBuilder` fait échouer l'extracteur `Session` (extension absente). Le token de référence devient `""` → soit tout est rejeté, soit comparaison de deux chaînes vides. **Garde-fou : placement strict après `session_layer`** (pièges axum #394 / #2070). | |
| 188 | +3. **Confusion ordre `ServiceBuilder` vs `Router::layer` chaîné** — ne pas mélanger les deux styles. `ServiceBuilder` = haut→bas ; `Router::layer()` chaîné = inverse. Rustwarden est en `ServiceBuilder` : rester cohérent. | |
| 189 | +4. **CORS passé sous le CSRF** — casserait les preflight `OPTIONS` (401/403 parasites). CORS doit rester le plus extérieur. (OPTIONS est de toute façon exempté par le CSRF, mais ne pas se reposer là-dessus.) | |
| 190 | +5. **Double lecture du corps de requête** — en `accept_form_body`, le middleware bufferise le body ; si le body n'est pas correctement reconstruit, l'extracteur `Form<…>` du handler échoue. Tester explicitement un POST form qui doit **réussir** avec un bon token. | |
| 191 | +6. **`max_form_body_bytes` vs gros uploads** — un POST volumineux légitime renvoie 413. S'assurer qu'aucune route browser n'attend un gros body (les uploads, s'il y en a, doivent passer côté machine ou être multipart, lequel n'est jamais bufferisé par le CSRF). | |
| 192 | +7. **Login lui-même** — `POST /login` (form) n'a pas encore de session établie au moment du submit : vérifier que le token est généré et stocké **à l'affichage** de la page de login (GET) puis validé au POST. Sinon premier login impossible. | |
| 193 | +8. **Désactivation « par commodité »** — ne jamais exposer un flag binaire « CSRF off » global ; l'exemption doit rester topologique (routeur machine) pour ne pas inviter à rouvrir le trou. | |
| 194 | +9. **Routes `fallback`/`nest` non héritées** (axum #3183) — vérifier que les layers s'appliquent bien à toutes les routes browser, y compris fallback. | |
| 195 | +10. **Incohérence de nommage** — un handler lit `csrf_token`, un template écrit `_csrf` : uniformiser strictement. | |
| 196 | +11. **Couture inter-crate (le vrai piège n°1bis)** — l'inbox et le consentement arrivent par le **même** `merge_routes()` opaque ([`main.rs:125`](../../app/src/main.rs#L125)/[`140`](../../app/src/main.rs#L140)). Sans canal explicite (D1) ou marqueur d'exemption, core ne peut pas les séparer : soit l'inbox prend le CSRF (fédération morte), soit le consentement passe sans (trou). **Garde-fou : D1 + tests T10c/T10d.** | |
| 197 | +12. **Régression silencieuse du défaut `merge_routes`** — selon D2, un consommateur existant qui montait une route machine via `merge_routes()` peut soudain recevoir un 403. Documenter la migration vers `merge_machine_routes()` et l'annoncer dans le changelog. | |
| 198 | + | |
| 199 | +--- | |
| 200 | + | |
| 201 | +## 7. Tests à ajouter | |
| 202 | + | |
| 203 | +> Cible : tests d'intégration Axum (`tower::ServiceExt::oneshot` sur le routeur assemblé) + tests unitaires ciblés. Respecter la contrainte projet : pas de `--all-features` en CI (feature `redis-session` cassée). | |
| 204 | + | |
| 205 | +### Tests d'intégration — routeur browser (cœur du contrat) | |
| 206 | +- **T1 — Rejet sans token.** `POST /settings/password` (cookie de session valide, **aucun** token) → **403**. | |
| 207 | +- **T2 — Rejet token invalide.** Idem avec `X-CSRF-Token` erroné → **403**. | |
| 208 | +- **T3 — Succès via header.** `PUT /profile` avec session + `X-CSRF-Token` correct → **2xx** (et effet métier appliqué). | |
| 209 | +- **T4 — Succès via form body.** `POST` form `application/x-www-form-urlencoded` avec `csrf_token=<valide>` → **2xx** (valide B3 + piège n°5 reconstruction du body). | |
| 210 | +- **T5 — Méthodes sûres non bloquées.** `GET`/`HEAD`/`OPTIONS` → jamais 403 même sans token. | |
| 211 | +- **T6 — Bypass Bearer.** `POST` avec `Authorization: Bearer <jwt>` et sans token CSRF → **non bloqué** (et vérifier qu'on ne peut pas abuser de ce bypass via cookie seul). | |
| 212 | +- **T6bis — Login.** `GET /login` pose le token ; `POST /login` avec ce token → OK ; `POST /login` sans token → 403 (piège n°7). | |
| 213 | + | |
| 214 | +### Tests d'intégration — routeur machine (non-régression critique) | |
| 215 | +- **T7 — Inbox ActivityPub jamais bloquée.** `POST /inbox` (signature HTTP, sans token CSRF) → **pas de 403 CSRF** (le code de retour dépend de la validation de signature, mais jamais 403 *pour cause de CSRF*). **Test anti-régression du piège n°1.** | |
| 216 | +- **T8 — API JSON Bearer.** `POST /api/v1/auth/...` côté machine → non soumis au CSRF. | |
| 217 | +- **T9 — Callback OAuth / webhook** (si routes présentes) → non bloqués. | |
| 218 | + | |
| 219 | +### Tests « secure-by-default » (garantie B1/O3) | |
| 220 | +- **T10 — Route mutante ajoutée = protégée.** Monter dans un test un routeur browser avec une route POST factice `/__test/echo` **sans** aucun code CSRF dans son handler, et vérifier qu'elle renvoie **403 sans token** et **2xx avec token**. C'est le test qui prouve que la protection est *structurelle* et non *disciplinaire*. | |
| 221 | + | |
| 222 | +### Tests de couture inter-crate (garantie « avec ou sans fédération », §1bis / D1) | |
| 223 | +- **T10a — Sans fédération, route tierce mutante.** Assembler une app **sans** crate fediverse, ajouter une route POST tierce via le canal browser (ou `merge_routes` selon **D2**) → **403 sans token / 2xx avec token**. Prouve la sûreté du consommateur en l'absence de fédération. | |
| 224 | +- **T10b — Sans fédération, route machine tierce.** Route POST tierce déclarée machine (canal machine) → **non soumise au CSRF**. Prouve l'existence d'une voie machine pour API/webhooks tiers. | |
| 225 | +- **T10c — Avec fédération, inbox via le câblage réel.** Reproduire le montage de [`main.rs`](../../app/src/main.rs) (inbox via son canal/marqueur) et confirmer que `POST /inbox` **n'est jamais 403 CSRF**, tandis que `POST /fediverse/consent/{id}` **exige** un token. Prouve que la couture place chaque famille du bon côté. | |
| 226 | +- **T10d — (si D1=B/C) Robustesse au mauvais câblage.** Monter délibérément l'inbox sur le canal browser : grâce au marqueur `CsrfExempt`, `POST /inbox` reste **non bloqué**. Prouve que la fédération ne peut pas être cassée par une erreur de branchement consommateur. | |
| 227 | + | |
| 228 | +### Tests unitaires (déjà partiellement présents dans `csrf.rs`) | |
| 229 | +- **T11** — `generate_csrf_token` : longueur 64 hex, deux appels ≠. | |
| 230 | +- **T12** — `csrf_tokens_match` : constant-time, vrai/faux corrects (conserver/renforcer l'existant). | |
| 231 | +- **T13** — `extract_csrf_from_form` : parsing `csrf_token=`, URL-decoding, absence → `None`. | |
| 232 | +- **T14** — Dépassement `max_form_body_bytes` → 413 (piège n°6). | |
| 233 | + | |
| 234 | +### Non-régression fediverse | |
| 235 | +- **T15** — Les flux `login/fediverse`, `consent`, `invite`, admin trust/invites/sessions restent fonctionnels (token form body validé) — exécuter la suite existante de `rustwarden-fediverse-ui` et `rustwarden-fediverse-guard` sans modification de comportement. | |
| 236 | + | |
| 237 | +--- | |
| 238 | + | |
| 239 | +## 8. Comment contrôler que le travail est correctement réalisé | |
| 240 | + | |
| 241 | +### Critères d'acceptation (Definition of Done) | |
| 242 | +- [ ] **DoD-1** Tous les tests T1–T15 (dont T10a–T10d) passent : `cargo test -p rustwarden-core -p rustwarden-ui -p rustwarden-fediverse-ui -p rustwarden-fediverse-guard`. | |
| 243 | +- [ ] **DoD-2** `cargo clippy --workspace -D warnings` propre (objectif zéro warning, constitution). | |
| 244 | +- [ ] **DoD-3** `cargo build --workspace` OK (sans `--all-features`, cf. `redis-session` cassée). | |
| 245 | +- [ ] **DoD-4** Le test **T10** (route factice protégée sans code handler) passe — preuve mécanique de B1/O3. | |
| 246 | +- [ ] **DoD-4bis** Les tests **T10a–T10d** passent — preuve que la sécurité est correcte **avec ET sans fédération** (§1bis), et robuste au mauvais câblage si D1=B/C. | |
| 247 | +- [ ] **DoD-5** Le test **T7** (inbox AP jamais 403 CSRF) passe — preuve de non-régression fédération. | |
| 248 | +- [ ] **DoD-6** Grep de contrôle : aucune route mutante browser ne contourne le layer (la cartographie A0.1 et le split sont la source de vérité ; vérifier qu'aucune route mutante n'est restée hors du routeur browser par erreur). | |
| 249 | +- [ ] **DoD-7** Doc à jour : `middleware/mod.rs` ne décrit plus le CSRF comme « câblé par défaut » de façon trompeuse ; guide consommateur présent. | |
| 250 | + | |
| 251 | +### Contrôles manuels (smoke test, en complément des tests auto) | |
| 252 | +1. **Reproduction de l'attaque (avant/après).** Avant : forger une page HTML tierce qui auto-soumet un `POST /admin/users` vers une instance avec admin connecté → l'utilisateur est créé (vulnérable). Après : la même page → **403**. Documenter le résultat. | |
| 253 | +2. **Parcours UI réel** (`cargo run -p rustwarden-app`) : login, modif profil, changement de mot de passe, action admin → tout fonctionne avec le token ; supprimer le token côté client via devtools → 403. | |
| 254 | +3. **Parcours fédération** : login fediverse + consent + invite → inchangé ; un message entrant sur `/inbox` depuis un serveur distant → accepté (pas de 403 CSRF). | |
| 255 | + | |
| 256 | +### Méthode de vérification (process) | |
| 257 | +- Conformément à `CLAUDE.md` : **séparer auteur et relecteur**. Une fois l'implémentation faite par un agent `executor`, faire passer un agent `code-reviewer`/`verifier` distinct pour l'approbation, avec les preuves (sortie de tests, sortie clippy) à l'appui — pas d'auto-validation dans le même contexte. | |
| 258 | +- Aucune affirmation « c'est protégé » sans la sortie de test correspondante jointe. | |
| 259 | + | |
| 260 | +--- | |
| 261 | + | |
| 262 | +## 9. Ordre d'exécution recommandé | |
| 263 | + | |
| 264 | +``` | |
| 265 | +A0 (cartographie) → A1 (câblage browser) → T1–T10 (contrat browser) | |
| 266 | + → T7–T9 (non-régression machine) | |
| 267 | + ↓ | |
| 268 | +A2 (templates UI) → parcours UI manuel | |
| 269 | + ↓ | |
| 270 | +A3 (décision fediverse) → T15 (non-régression fediverse) | |
| 271 | + ↓ | |
| 272 | +A4 (doc) → revue séparée (verifier) → DoD complète | |
| 273 | +``` | |
| 274 | + | |
| 275 | +A0 est **bloquant** : sans cartographie fiable des familles de routes, le split est hasardeux et le piège n°1 (inbox) devient probable. | |
| 276 | + | |
| 277 | +--- | |
| 278 | + | |
| 279 | +## Annexe A — Cartographie des routes (établie par lecture du code, 2026-06-04) | |
| 280 | + | |
| 281 | +Source : [`rustwarden-ui/src/routes.rs`](../../crates/rustwarden-ui/src/routes.rs), [`app.rs:455-483`](../../crates/rustwarden-core/src/app.rs#L455-L483) (oauth), [`rustwarden-fediverse/src/routes.rs`](../../crates/rustwarden-fediverse/src/routes.rs), [`rustwarden-fediverse-ui/src/routes.rs`](../../crates/rustwarden-fediverse-ui/src/routes.rs). | |
| 282 | + | |
| 283 | +### Browser (CSRF) — routes SSR pilotées par cookie de session | |
| 284 | +| Route | Méthode mutante | Origine | | |
| 285 | +|---|---|---| | |
| 286 | +| `/` , `/login`, `/reset-password`, `/resend-verification`, `/confirm-password-change`, `/login/2fa`, `/health` | GET (sûres) | rustwarden-ui `public_routes` | | |
| 287 | +| `/home` | GET | rustwarden-ui | | |
| 288 | +| `/profile` | **PUT** | rustwarden-ui `ui_routes` | | |
| 289 | +| `/settings/password` | **PUT** ; `/settings/password/pending` **DELETE** | rustwarden-ui | | |
| 290 | +| `/settings/sessions/{id}` | **DELETE** | rustwarden-ui | | |
| 291 | +| `/settings/preferences` | **PUT** | rustwarden-ui | | |
| 292 | +| `/settings/security/totp/{enable,verify,disable}`, `/settings/security/backup-codes` | **POST** | rustwarden-ui | | |
| 293 | +| `/admin/users` `/admin/users/{id}` `/admin/users/{id}/reset-password` `…/reset-password-email` `…/reset-totp` | **POST/PUT/DELETE** | rustwarden-ui `admin_routes` | | |
| 294 | +| `/admin/roles` `/admin/roles/{id}` | **POST/PUT/DELETE** | rustwarden-ui | | |
| 295 | +| `/admin/settings` | **PUT** | rustwarden-ui | | |
| 296 | +| `/static/*` | GET | rustwarden-ui `nest_service` | | |
| 297 | + | |
| 298 | +> Tous les `fetch()` de ces routes tournent en `credentials:'include'` (cookie) **sans Bearer** → c'est exactement la surface CSRF, et elle exige donc l'envoi du token (Phase 2). | |
| 299 | + | |
| 300 | +### Machine (PAS de CSRF) | |
| 301 | +| Route | Méthode | Origine | Justification | | |
| 302 | +|---|---|---|---| | |
| 303 | +| `/api/v1/auth/*` (login, register, refresh, logout, me, forgot/reset-password, verify-email, resend-verification, 2fa/verify) | POST/GET | rustwarden-ui `auth_routes` | API JSON ; login/register **pré-session** (aucun token possible) ; clients SPA/mobile. Bypass Bearer pour les appels authentifiés. | | |
| 304 | +| `/api/v1/users` | POST/GET | rustwarden-ui `user_routes` | API JSON (Bearer). | | |
| 305 | +| `/api/v1/auth/oauth/*` | GET + 1 DELETE | core `oauth_routes` (feature `oauth`) | API JSON ; GET sûrs ; DELETE unlink → Bearer/JSON. | | |
| 306 | +| `/inbox` | POST | rustwarden-fediverse | **signature HTTP**, serveur distant — aucun token CSRF possible. ✅ Lot 2 : confirmé sur le canal machine, jamais 403 CSRF (test `csrf_seam::t10c_inbox*` / `t10d`). | | |
| 307 | +| `.well-known/*`, autres GET fediverse | GET | rustwarden-fediverse | public / méthode sûre. | | |
| 308 | + | |
| 309 | +### Fediverse-UI (browser) — ✅ promu en **Lot 2** | |
| 310 | +`/login/fediverse*`, `/fediverse/consent/{id}`, `/fediverse/invite/{id}`, `/admin/fediverse/*` — famille **browser**. Depuis le Lot 2, montée via `merge_browser_routes` → canal **CSRF-protégé**. Les `validate_csrf_token` **par handler** (10 occurrences) sont **conservés** en defense-in-depth (D3=a). La double-lecture du body est vérifiée (A3.2, test `csrf_seam`). | |
| 311 | + | |
| 312 | +### Fediverse-Guard UI (browser) — ✅ promu en **Lot 2** | |
| 313 | +`/admin/fediverse-guard*` (dont `POST /admin/fediverse-guard/manual_ban`) — famille **browser**, formulaire SSR avec hidden `csrf_token` + handler `validate_csrf_token`. En Lot 1 elle chevauchait le `fediverse_router` (machine) ; en Lot 2 elle est **séparée** en un `guard_ui_router` distinct monté via `merge_browser_routes`, et son préfixe `/admin/fediverse-guard` est enregistré pour l'auth middleware. Le `fediverse_router` ne porte plus que l'observer + l'inbox (machine). | |
| 314 | + | |
| 315 | +--- | |
| 316 | + | |
| 317 | +## Annexe B — Réalisation de D1 (note technique d'implémentation) | |
| 318 | + | |
| 319 | +**Le « marqueur runtime `CsrfExempt` » de D1=C n'est pas réalisable proprement** avec un layer CSRF unique externe : une extension posée via `route_layer` est injectée *à l'intérieur* du routage, donc **invisible** au middleware externe qui s'exécute avant le dispatch. Un bypass basé sur un header (`Signature:`) serait **dangereux** sur les routes browser (header forgeable → contournement CSRF sur `/admin/*`). | |
| 320 | + | |
| 321 | +**Réalisation retenue (substance de C, sûre) :** exemption **purement topologique** via deux canaux, + **accesseurs machine dédiés et infaillibles** exposés par chaque crate propriétaire pour que le chemin documenté soit le seul raisonnable : | |
| 322 | +- `RustwardenBuilder::merge_browser_routes(Router)` → sous-routeur browser (CSRF appliqué avant `merge`). | |
| 323 | +- `RustwardenBuilder::merge_machine_routes(Router)` → sous-routeur machine (jamais de CSRF). | |
| 324 | +- `merge_routes()` **= browser par défaut** (D2=A), conservé pour compat, documenté. | |
| 325 | +- `rustwarden-ui` expose `browser_routes()` (SSR) et `api_routes()` (`/api/v1/*`). | |
| 326 | +- `rustwarden-fediverse` : l'inbox est monté via `merge_machine_routes` (Lot 1, une ligne). | |
| 327 | + | |
| 328 | +> T10d (« robustesse au mauvais câblage ») est requalifié : on **ne** garantit pas la survie d'un branchement délibérément erroné (inbox sur canal browser) ; on garantit que **l'API documentée rend le bon câblage évident** et qu'un test structurel vérifie que le bundle machine de fediverse contient bien `/inbox`. | |
| 329 | + | |
| 330 | +--- | |
| 331 | + | |
| 332 | +## Annexe C — Consommateurs à migrer (impact D2=A + partition UI) | |
| 333 | + | |
| 334 | +La partition de `rustwarden-ui` (les `/api/v1/*` quittent `default_routes`) impose de monter explicitement le canal machine. Fichiers à mettre à jour : | |
| 335 | +- [`app/src/main.rs`](../../app/src/main.rs) — `with_ui(browser_routes)` + `merge_machine_routes(api_routes())` ; inbox → `merge_machine_routes`. | |
| 336 | +- [`app/examples/with_default_ui.rs`](../../app/examples/with_default_ui.rs), [`custom_hooks.rs`](../../app/examples/custom_hooks.rs) — idem (UI + api_routes). | |
| 337 | +- [`app/examples/minimal_headless.rs`](../../app/examples/minimal_headless.rs) — `merge_routes(api_routes())` → `merge_machine_routes(api_routes())` (routes API tierces). | |
| 338 | +- [`template/src/main.rs`](../../template/src/main.rs) et [`tools/rustwarden-cli/src/templates.rs`](../../tools/rustwarden-cli/src/templates.rs) — chaînes de scaffolding (UI + api_routes ; `app_routes()` reste browser par défaut sauf si l'utilisateur déclare une API). | |
| 339 | +- Compat `default_routes()` : conservée mais **dépréciée** (renvoie `browser_routes().merge(api_routes())`) ; ne plus l'utiliser avec `with_ui` car cela remettrait `/api/v1/*` derrière le CSRF. |
M
template/src/main.rs
+2
-1
@@ -6,7 +6,8 @@ use sea_orm::DatabaseConnection;
| 6 | 6 | async fn main() -> anyhow::Result<()> { |
| 7 | 7 | RustwardenApp::builder() |
| 8 | 8 | .from_env() |
| 9 | - .with_ui(rustwarden_ui::default_routes) | |
| 9 | + .with_ui(rustwarden_ui::browser_routes) | |
| 10 | + .merge_machine_routes(rustwarden_ui::api_routes()) | |
| 10 | 11 | .auto_migrate(true) |
| 11 | 12 | .merge_routes(app_routes()) |
| 12 | 13 | .build() |
M
tools/rustwarden-cli/src/templates.rs
+13
-3
@@ -205,7 +205,9 @@ empty_line_after_doc_comments = "allow"
| 205 | 205 | |
| 206 | 206 | pub fn main_rs(cfg: &ProjectConfig) -> String { |
| 207 | 207 | let ui_line = if cfg.with_ui { |
| 208 | - " .with_ui(rustwarden_ui::default_routes)\n" | |
| 208 | + // Browser SSR routes go through the CSRF-protected channel; the | |
| 209 | + // `/api/v1/*` JSON API rides the machine channel (no CSRF). | |
| 210 | + " .with_ui(rustwarden_ui::browser_routes)\n .merge_machine_routes(rustwarden_ui::api_routes())\n" | |
| 209 | 211 | } else { |
| 210 | 212 | " .headless()\n" |
| 211 | 213 | }; |
@@ -244,7 +246,13 @@ pub fn main_rs(cfg: &ProjectConfig) -> String {
| 244 | 246 | // dashboard. |
| 245 | 247 | #[cfg(feature = "fediverse-ui")] |
| 246 | 248 | let builder = builder |
| 247 | - .merge_routes(rustwarden_fediverse_ui::default_routes()) | |
| 249 | + // Famille browser : login fediverse, consent, invite et ecrans | |
| 250 | + // admin sont des formulaires SSR pilotes par cookie de session ; | |
| 251 | + // on les monte sur le canal CSRF-protege. Chaque handler garde son | |
| 252 | + // validate_csrf_token en defense-in-depth ; le layer CSRF global | |
| 253 | + // lit le token dans le body urlencoded et le reconstruit pour | |
| 254 | + // l'extracteur Form<...> aval. L'inbox ActivityPub reste machine. | |
| 255 | + .merge_browser_routes(rustwarden_fediverse_ui::default_routes()) | |
| 248 | 256 | .add_dashboard_card(rustwarden_fediverse_ui::dashboard_card()); |
| 249 | 257 | "# |
| 250 | 258 | } else { |
@@ -272,12 +280,14 @@ pub fn main_rs(cfg: &ProjectConfig) -> String {
| 272 | 280 | db.clone(), |
| 273 | 281 | ), |
| 274 | 282 | ); |
| 283 | + // L'inbox ActivityPub (POST /inbox, signature HTTP) ne peut pas | |
| 284 | + // porter de token CSRF : on la monte sur le canal machine. | |
| 275 | 285 | builder |
| 276 | 286 | .database(db) |
| 277 | 287 | .register_protected_prefix("/fediverse/consent") |
| 278 | 288 | .register_protected_prefix("/fediverse/invite") |
| 279 | 289 | .register_revocation_checker(checker) |
| 280 | - .merge_routes(fediverse_router) | |
| 290 | + .merge_machine_routes(fediverse_router) | |
| 281 | 291 | }; |
| 282 | 292 | "#, |
| 283 | 293 | fediverse_ui, |
GitRust