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,