Ansi audit

EBO <eric.bouhana@softalys.com> committé le 2026-05-14 20:44

4517f4109edbe65ba0fb447fbf68d336e41f6996

1 parent(s)

26 fichiers modifiés +2007 -679
M Cargo.lock
+11 -72
@@ -218,15 +218,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
218 218 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
219 219
220 220 [[package]]
221 -name = "ar_archive_writer"
222 -version = "0.5.1"
223 -source = "registry+https://github.com/rust-lang/crates.io-index"
224 -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
225 -dependencies = [
226 - "object",
227 -]
228 -
229 -[[package]]
230 221 name = "arc-swap"
231 222 version = "1.8.2"
232 223 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -871,16 +862,6 @@ dependencies = [
871 862 ]
872 863
873 864 [[package]]
874 -name = "chumsky"
875 -version = "0.9.3"
876 -source = "registry+https://github.com/rust-lang/crates.io-index"
877 -checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
878 -dependencies = [
879 - "hashbrown 0.14.5",
880 - "stacker",
881 -]
882 -
883 -[[package]]
884 865 name = "cipher"
885 866 version = "0.4.4"
886 867 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1555,7 +1536,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1555 1536 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1556 1537 dependencies = [
1557 1538 "libc",
1558 - "windows-sys 0.61.2",
1539 + "windows-sys 0.52.0",
1559 1540 ]
1560 1541
1561 1542 [[package]]
@@ -1981,16 +1962,6 @@ dependencies = [
1981 1962
1982 1963 [[package]]
1983 1964 name = "hashbrown"
1984 -version = "0.14.5"
1985 -source = "registry+https://github.com/rust-lang/crates.io-index"
1986 -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
1987 -dependencies = [
1988 - "ahash 0.8.12",
1989 - "allocator-api2",
1990 -]
1991 -
1992 -[[package]]
1993 -name = "hashbrown"
1994 1965 version = "0.15.5"
1995 1966 source = "registry+https://github.com/rust-lang/crates.io-index"
1996 1967 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
@@ -2249,7 +2220,7 @@ dependencies = [
2249 2220 "libc",
2250 2221 "percent-encoding",
2251 2222 "pin-project-lite",
2252 - "socket2 0.6.3",
2223 + "socket2 0.5.10",
2253 2224 "system-configuration",
2254 2225 "tokio",
2255 2226 "tower-layer",
@@ -2582,13 +2553,12 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
2582 2553
2583 2554 [[package]]
2584 2555 name = "lettre"
2585 -version = "0.11.19"
2556 +version = "0.11.22"
2586 2557 source = "registry+https://github.com/rust-lang/crates.io-index"
2587 -checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
2558 +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
2588 2559 dependencies = [
2589 2560 "async-trait",
2590 2561 "base64 0.22.1",
2591 - "chumsky",
2592 2562 "email-encoding",
2593 2563 "email_address",
2594 2564 "fastrand",
@@ -3076,7 +3046,7 @@ version = "5.0.0"
3076 3046 source = "registry+https://github.com/rust-lang/crates.io-index"
3077 3047 checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
3078 3048 dependencies = [
3079 - "base64 0.22.1",
3049 + "base64 0.21.7",
3080 3050 "chrono",
3081 3051 "getrandom 0.2.17",
3082 3052 "http",
@@ -3091,15 +3061,6 @@ dependencies = [
3091 3061 ]
3092 3062
3093 3063 [[package]]
3094 -name = "object"
3095 -version = "0.37.3"
3096 -source = "registry+https://github.com/rust-lang/crates.io-index"
3097 -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
3098 -dependencies = [
3099 - "memchr",
3100 -]
3101 -
3102 -[[package]]
3103 3064 name = "once_cell"
3104 3065 version = "1.21.3"
3105 3066 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3562,16 +3523,6 @@ dependencies = [
3562 3523 ]
3563 3524
3564 3525 [[package]]
3565 -name = "psm"
3566 -version = "0.1.30"
3567 -source = "registry+https://github.com/rust-lang/crates.io-index"
3568 -checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
3569 -dependencies = [
3570 - "ar_archive_writer",
3571 - "cc",
3572 -]
3573 -
3574 -[[package]]
3575 3526 name = "ptr_meta"
3576 3527 version = "0.1.4"
3577 3528 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3627,7 +3578,7 @@ dependencies = [
3627 3578 "quinn-udp",
3628 3579 "rustc-hash",
3629 3580 "rustls",
3630 - "socket2 0.6.3",
3581 + "socket2 0.5.10",
3631 3582 "thiserror 2.0.18",
3632 3583 "tokio",
3633 3584 "tracing",
@@ -3664,9 +3615,9 @@ dependencies = [
3664 3615 "cfg_aliases",
3665 3616 "libc",
3666 3617 "once_cell",
3667 - "socket2 0.6.3",
3618 + "socket2 0.5.10",
3668 3619 "tracing",
3669 - "windows-sys 0.59.0",
3620 + "windows-sys 0.52.0",
3670 3621 ]
3671 3622
3672 3623 [[package]]
@@ -4076,7 +4027,7 @@ dependencies = [
4076 4027 "errno",
4077 4028 "libc",
4078 4029 "linux-raw-sys",
4079 - "windows-sys 0.61.2",
4030 + "windows-sys 0.52.0",
4080 4031 ]
4081 4032
4082 4033 [[package]]
@@ -4255,6 +4206,7 @@ dependencies = [
4255 4206 "serde_json",
4256 4207 "time",
4257 4208 "tokio",
4209 + "tower",
4258 4210 "tower-sessions",
4259 4211 "tracing",
4260 4212 "uuid",
@@ -5029,19 +4981,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5029 4981 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
5030 4982
5031 4983 [[package]]
5032 -name = "stacker"
5033 -version = "0.1.23"
5034 -source = "registry+https://github.com/rust-lang/crates.io-index"
5035 -checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
5036 -dependencies = [
5037 - "cc",
5038 - "cfg-if",
5039 - "libc",
5040 - "psm",
5041 - "windows-sys 0.59.0",
5042 -]
5043 -
5044 -[[package]]
5045 4984 name = "static_assertions"
5046 4985 version = "1.1.0"
5047 4986 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5186,7 +5125,7 @@ dependencies = [
5186 5125 "getrandom 0.4.2",
5187 5126 "once_cell",
5188 5127 "rustix",
5189 - "windows-sys 0.61.2",
5128 + "windows-sys 0.52.0",
5190 5129 ]
5191 5130
5192 5131 [[package]]
M app/src/main.rs
+26 -2
@@ -23,8 +23,32 @@ async fn main() -> anyhow::Result<()> {
23 23 let db = sea_orm::Database::connect(&db_url).await?;
24 24 let fediverse_config = rustwarden_fediverse::FediverseConfig::from_env()
25 25 .map_err(|e| anyhow::anyhow!("invalid FediverseConfig: {e}"))?;
26 - let fediverse_router =
27 - rustwarden_fediverse::routes::router(fediverse_config, db.clone());
26 +
27 + // ANSSI F-NET-5 — load AuthConfig explicitly so misconfiguration
28 + // surfaces as a startup error rather than silently booting with
29 + // a placeholder JWT secret.
30 + let auth_config: Arc<rustwarden_core::config::auth::AuthConfig> = Arc::new(
31 + rustwarden_core::config::auth::AuthConfig::from_env()
32 + .map_err(|e| anyhow::anyhow!("invalid AuthConfig for fediverse: {e}"))?,
33 + );
34 +
35 + // ANSSI F-CRYPTO-1/2 — wire the production `HttpSigVerifier`,
36 + // which now verifies the Digest header and the Date skew
37 + // window (5 min). Replaces the dev-only `AlwaysAcceptVerifier`.
38 + let resolver: Arc<dyn rustwarden_fediverse::resolver::LocalUserResolver> = Arc::new(
39 + rustwarden_fediverse::resolver::SeaOrmLocalUserResolver::new(db.clone()),
40 + );
41 + let verifier: Arc<
42 + dyn rustwarden_fediverse::services::fediverse_signal_service::SignatureVerifier,
43 + > = Arc::new(rustwarden_fediverse::http_signatures::HttpSigVerifier::with_resolver(
44 + db.clone(),
45 + ));
46 + let fediverse_router = rustwarden_fediverse::routes::router_with_auth(
47 + fediverse_config,
48 + resolver,
49 + verifier,
50 + auth_config,
51 + );
28 52 let checker: Arc<
29 53 dyn rustwarden_core::middleware::revocation::RemoteIdentityRevocationChecker,
30 54 > = Arc::new(
M crates/rustwarden-core/Cargo.toml
+2 -2
@@ -50,7 +50,7 @@ base64 = "0.22"
50 50 hex = "0.4"
51 51 subtle = "2.6"
52 52 # Email
53 -lettre = { version = "0.11", features = ["tokio1-native-tls", "builder"] }
53 +lettre = { version = "0.11.22", features = ["tokio1-native-tls", "builder"] }
54 54 # IMAP client for bounce processing
55 55 imap = "2.4"
56 56 native-tls = "0.2"
@@ -80,7 +80,7 @@ workspace = true
80 80
81 81 [[bin]]
82 82 name = "migrate"
83 -path = "migrations/src/main.rs"
83 +path = "src/bin/migrate.rs"
84 84
85 85 [[bin]]
86 86 name = "hash_password"
D crates/rustwarden-core/migrations/src/lib.rs
+0 -30
@@ -1,30 +0,0 @@
1 -pub use sea_orm_migration::prelude::*;
2 -
3 -pub mod m20260305_000001_initial_schema;
4 -pub mod m20260306_000002_create_app_settings_table;
5 -pub mod m20260309_000003_create_permissions_tables;
6 -pub mod m20260309_000004_create_resources_tables;
7 -pub mod m20260310_000005_create_oauth_accounts_table;
8 -pub mod m20260409_000006_create_totp_tables;
9 -pub mod m20260422_000007_create_personal_access_tokens;
10 -pub mod m20260423_000008_create_teams;
11 -pub mod m20260510_000009_fediverse_interop;
12 -
13 -pub struct Migrator;
14 -
15 -#[async_trait::async_trait]
16 -impl MigratorTrait for Migrator {
17 - fn migrations() -> Vec<Box<dyn MigrationTrait>> {
18 - vec![
19 - Box::new(m20260305_000001_initial_schema::Migration),
20 - Box::new(m20260306_000002_create_app_settings_table::Migration),
21 - Box::new(m20260309_000003_create_permissions_tables::Migration),
22 - Box::new(m20260309_000004_create_resources_tables::Migration),
23 - Box::new(m20260310_000005_create_oauth_accounts_table::Migration),
24 - Box::new(m20260409_000006_create_totp_tables::Migration),
25 - Box::new(m20260422_000007_create_personal_access_tokens::Migration),
26 - Box::new(m20260423_000008_create_teams::Migration),
27 - Box::new(m20260510_000009_fediverse_interop::Migration),
28 - ]
29 - }
30 -}
D crates/rustwarden-core/migrations/src/main.rs
+0 -11
@@ -1,11 +0,0 @@
1 -//! CLI entry point for running SeaORM migrations against the Rustwarden database.
2 -
3 -use sea_orm_migration::prelude::*;
4 -
5 -#[path = "lib.rs"]
6 -mod migrations;
7 -
8 -#[tokio::main]
9 -async fn main() {
10 - cli::run_cli(migrations::Migrator).await;
11 -}
A crates/rustwarden-core/src/bin/migrate.rs
+16 -0
@@ -0,0 +1,16 @@
1 +//! CLI entry point for running SeaORM migrations against the Rustwarden
2 +//! database.
3 +//!
4 +//! Delegates to [`rustwarden_core::migrations::CoreMigrator`], the same
5 +//! migrator used by the embedded auto-migrator. There is no separate
6 +//! migration list — adding a migration in
7 +//! `rustwarden-core/src/migrations/mod.rs` makes it available to both
8 +//! this binary and `RustwardenApp::auto_migrate(true)`.
9 +
10 +use rustwarden_core::migrations::CoreMigrator;
11 +use sea_orm_migration::prelude::*;
12 +
13 +#[tokio::main]
14 +async fn main() {
15 + cli::run_cli(CoreMigrator).await;
16 +}
D crates/rustwarden-core/src/migrations.rs
+0 -88
@@ -1,88 +0,0 @@
1 -//! Database migrations for Rustwarden
2 -//!
3 -//! Exposes the core migrations so consumer applications can combine them
4 -//! with their own application-specific migrations.
5 -//!
6 -//! # Example
7 -//!
8 -//! ```rust,ignore
9 -//! use sea_orm_migration::prelude::*;
10 -//! use rustwarden_core::migrations;
11 -//!
12 -//! struct AppMigrator;
13 -//!
14 -//! #[async_trait::async_trait]
15 -//! impl MigratorTrait for AppMigrator {
16 -//! fn migrations() -> Vec<Box<dyn MigrationTrait>> {
17 -//! let mut all = migrations::core_migrations();
18 -//! all.push(Box::new(MyCustomMigration));
19 -//! all
20 -//! }
21 -//! }
22 -//! ```
23 -
24 -/// Migration 1: initial schema (users, roles, refresh tokens, audit logs).
25 -#[path = "../migrations/src/m20260305_000001_initial_schema.rs"]
26 -pub mod m20260305_000001_initial_schema;
27 -
28 -/// Migration 2: application settings table.
29 -#[path = "../migrations/src/m20260306_000002_create_app_settings_table.rs"]
30 -pub mod m20260306_000002_create_app_settings_table;
31 -
32 -/// Migration 3: RBAC permissions tables.
33 -#[path = "../migrations/src/m20260309_000003_create_permissions_tables.rs"]
34 -pub mod m20260309_000003_create_permissions_tables;
35 -
36 -/// Migration 4: generic resources and resource-shares tables.
37 -#[path = "../migrations/src/m20260309_000004_create_resources_tables.rs"]
38 -pub mod m20260309_000004_create_resources_tables;
39 -
40 -/// Migration 5: OAuth accounts table.
41 -#[path = "../migrations/src/m20260310_000005_create_oauth_accounts_table.rs"]
42 -pub mod m20260310_000005_create_oauth_accounts_table;
43 -
44 -/// Migration 6: TOTP challenges and user-TOTP tables.
45 -#[path = "../migrations/src/m20260409_000006_create_totp_tables.rs"]
46 -pub mod m20260409_000006_create_totp_tables;
47 -
48 -/// Migration 7: personal access tokens table.
49 -#[path = "../migrations/src/m20260422_000007_create_personal_access_tokens.rs"]
50 -pub mod m20260422_000007_create_personal_access_tokens;
51 -
52 -/// Migration 8: teams and team-members tables.
53 -#[path = "../migrations/src/m20260423_000008_create_teams.rs"]
54 -pub mod m20260423_000008_create_teams;
55 -
56 -use sea_orm_migration::prelude::*;
57 -
58 -/// Returns all core Rustwarden migrations in order.
59 -pub fn core_migrations() -> Vec<Box<dyn MigrationTrait>> {
60 - vec![
61 - Box::new(m20260305_000001_initial_schema::Migration),
62 - Box::new(m20260306_000002_create_app_settings_table::Migration),
63 - Box::new(m20260309_000003_create_permissions_tables::Migration),
64 - Box::new(m20260309_000004_create_resources_tables::Migration),
65 - Box::new(m20260310_000005_create_oauth_accounts_table::Migration),
66 - Box::new(m20260409_000006_create_totp_tables::Migration),
67 - Box::new(m20260422_000007_create_personal_access_tokens::Migration),
68 - Box::new(m20260423_000008_create_teams::Migration),
69 - ]
70 -}
71 -
72 -/// Core migrator that runs all Rustwarden migrations.
73 -pub struct CoreMigrator;
74 -
75 -#[async_trait::async_trait]
76 -impl MigratorTrait for CoreMigrator {
77 - fn migrations() -> Vec<Box<dyn MigrationTrait>> {
78 - core_migrations()
79 - }
80 -}
81 -
82 -/// Run all pending core migrations on the database.
83 -pub async fn run_migrations(
84 - db: &sea_orm::DatabaseConnection,
85 -) -> Result<(), sea_orm_migration::DbErr> {
86 - use sea_orm_migration::MigratorTrait;
87 - CoreMigrator::up(db, None).await
88 -}
M crates/rustwarden-core/src/migrations/m20260510_000009_fediverse_interop.rs
+3 -0
@@ -20,6 +20,9 @@
20 20 use sea_orm_migration::prelude::*;
21 21 use sea_orm_migration::sea_orm::ConnectionTrait;
22 22
23 +/// Fediverse interop migration: extends `users` with ActivityPub actor
24 +/// columns, creates `remote_identities`, and adds sibling FK columns to
25 +/// `team_members`, `resource_shares`, `audit_logs`.
23 26 #[derive(DeriveMigrationName)]
24 27 pub struct Migration;
25 28
A crates/rustwarden-core/src/migrations/mod.rs
+90 -0
@@ -0,0 +1,90 @@
1 +//! Database migrations for Rustwarden.
2 +//!
3 +//! Single source of truth for all SeaORM migrations bundled with the
4 +//! framework. Both the embedded auto-migrator (used by
5 +//! [`crate::app::RustwardenApp::auto_migrate`]) and the standalone
6 +//! `migrate` binary read this list, so adding a migration in one place
7 +//! is sufficient — there is no parallel list to keep in sync.
8 +//!
9 +//! # Adding a new migration
10 +//!
11 +//! 1. Create `mYYYYMMDD_NNNNNN_<name>.rs` in this directory.
12 +//! 2. Add a `pub mod` declaration below.
13 +//! 3. Append `Box::new(mYYYYMMDD_NNNNNN_<name>::Migration)` to
14 +//! [`core_migrations`].
15 +//!
16 +//! # Example: extending with application-specific migrations
17 +//!
18 +//! ```rust,ignore
19 +//! use sea_orm_migration::prelude::*;
20 +//! use rustwarden_core::migrations;
21 +//!
22 +//! struct AppMigrator;
23 +//!
24 +//! #[async_trait::async_trait]
25 +//! impl MigratorTrait for AppMigrator {
26 +//! fn migrations() -> Vec<Box<dyn MigrationTrait>> {
27 +//! let mut all = migrations::core_migrations();
28 +//! all.push(Box::new(MyCustomMigration));
29 +//! all
30 +//! }
31 +//! }
32 +//! ```
33 +
34 +/// Migration 1: initial schema (users, roles, refresh tokens, audit logs).
35 +pub mod m20260305_000001_initial_schema;
36 +/// Migration 2: application settings table.
37 +pub mod m20260306_000002_create_app_settings_table;
38 +/// Migration 3: RBAC permissions tables.
39 +pub mod m20260309_000003_create_permissions_tables;
40 +/// Migration 4: generic resources and resource-shares tables.
41 +pub mod m20260309_000004_create_resources_tables;
42 +/// Migration 5: OAuth accounts table.
43 +pub mod m20260310_000005_create_oauth_accounts_table;
44 +/// Migration 6: TOTP challenges and user-TOTP tables.
45 +pub mod m20260409_000006_create_totp_tables;
46 +/// Migration 7: personal access tokens table.
47 +pub mod m20260422_000007_create_personal_access_tokens;
48 +/// Migration 8: teams and team-members tables.
49 +pub mod m20260423_000008_create_teams;
50 +/// Migration 9: fediverse interop columns and `remote_identities` table.
51 +pub mod m20260510_000009_fediverse_interop;
52 +
53 +use sea_orm_migration::prelude::*;
54 +
55 +/// Returns all core Rustwarden migrations in order.
56 +pub fn core_migrations() -> Vec<Box<dyn MigrationTrait>> {
57 + vec![
58 + Box::new(m20260305_000001_initial_schema::Migration),
59 + Box::new(m20260306_000002_create_app_settings_table::Migration),
60 + Box::new(m20260309_000003_create_permissions_tables::Migration),
61 + Box::new(m20260309_000004_create_resources_tables::Migration),
62 + Box::new(m20260310_000005_create_oauth_accounts_table::Migration),
63 + Box::new(m20260409_000006_create_totp_tables::Migration),
64 + Box::new(m20260422_000007_create_personal_access_tokens::Migration),
65 + Box::new(m20260423_000008_create_teams::Migration),
66 + Box::new(m20260510_000009_fediverse_interop::Migration),
67 + ]
68 +}
69 +
70 +/// SeaORM migrator running every core Rustwarden migration in order.
71 +///
72 +/// Used both by the embedded [`run_migrations`] helper and by the
73 +/// standalone `migrate` binary. Implements [`MigratorTrait`] so it can
74 +/// be passed to `cli::run_cli` for `up` / `down` / `refresh` / `status`
75 +/// subcommands.
76 +pub struct CoreMigrator;
77 +
78 +#[async_trait::async_trait]
79 +impl MigratorTrait for CoreMigrator {
80 + fn migrations() -> Vec<Box<dyn MigrationTrait>> {
81 + core_migrations()
82 + }
83 +}
84 +
85 +/// Run all pending core migrations on the database.
86 +pub async fn run_migrations(
87 + db: &sea_orm::DatabaseConnection,
88 +) -> Result<(), sea_orm_migration::DbErr> {
89 + CoreMigrator::up(db, None).await
90 +}
M crates/rustwarden-core/src/services/crypto_service.rs
+5 -2
@@ -74,9 +74,12 @@ impl CryptoService {
74 74 }
75 75
76 76 let (nonce_bytes, ciphertext) = combined.split_at(12);
77 - let nonce = Nonce::from_slice(nonce_bytes);
77 + let nonce_array: [u8; 12] = nonce_bytes
78 + .try_into()
79 + .map_err(|_| AppError::Internal("Invalid nonce length".to_string()))?;
80 + let nonce = Nonce::from(nonce_array);
78 81
79 - let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_| {
82 + let plaintext = cipher.decrypt(&nonce, ciphertext).map_err(|_| {
80 83 AppError::Internal("Decryption failed – wrong key or corrupted data".to_string())
81 84 })?;
82 85
M crates/rustwarden-fediverse-ui/Cargo.toml
+1 -0
@@ -20,6 +20,7 @@ tracing = "0.1"
20 20 sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }
21 21 uuid = { version = "1.0", features = ["v4", "serde"] }
22 22 chrono = { version = "0.4", features = ["serde"] }
23 +tower = { version = "0.5", features = ["limit"] }
23 24 tower-sessions = "0.14"
24 25 axum-extra = { version = "0.12", features = ["cookie"] }
25 26 validator = { version = "0.20", features = ["derive"] }
M crates/rustwarden-fediverse-ui/src/handlers/admin.rs
+95 -0
@@ -730,11 +730,19 @@ pub async fn add_trust_entry(
730 730 /// Apply `POST /admin/fediverse/trust_list/delete`. Removes an entry by
731 731 /// canonical domain (URL-safe form input) from the active list.
732 732 pub async fn delete_trust_entry(
733 + Extension(claims): Extension<Claims>,
733 734 State(db): State<DatabaseConnection>,
734 735 session: Session,
735 736 Form(form): Form<DeleteTrustEntryForm>,
736 737 ) -> Result<impl IntoResponse, AppError> {
737 738 csrf::validate_csrf_token(&session, &form.csrf_token).await?;
739 + // ANSSI FUI-AUTH-1 — audit trail for destructive trust mutation.
740 + tracing::info!(
741 + admin_id = %claims.sub,
742 + domain = %form.domain,
743 + action = "delete_trust_entry",
744 + "admin destructive fediverse action",
745 + );
738 746
739 747 let trust = FediverseTrustService::new(db.clone());
740 748 if !trust.is_enabled().await? {
@@ -1192,11 +1200,19 @@ pub async fn get_sessions_remote(
1192 1200 /// `POST /admin/fediverse/sessions_remote/revoke` — set `disabled_at`
1193 1201 /// on a single remote_identities row.
1194 1202 pub async fn revoke_remote_session(
1203 + Extension(claims): Extension<Claims>,
1195 1204 State(db): State<DatabaseConnection>,
1196 1205 session: Session,
1197 1206 Form(form): Form<RevokeRemoteForm>,
1198 1207 ) -> Result<axum::response::Response, AppError> {
1199 1208 csrf::validate_csrf_token(&session, &form.csrf_token).await?;
1209 + // ANSSI FUI-AUTH-1 — audit trail for remote session revocation.
1210 + tracing::info!(
1211 + admin_id = %claims.sub,
1212 + remote_identity_id = %form.id,
1213 + action = "revoke_remote_session",
1214 + "admin destructive fediverse action",
1215 + );
1200 1216
1201 1217 let trust = FediverseTrustService::new(db.clone());
1202 1218 if !trust.is_enabled().await? {
@@ -1222,11 +1238,19 @@ pub async fn revoke_remote_session(
1222 1238 /// every remote_identities row from a given domain. Used as a panic
1223 1239 /// button when an entire federated instance is compromised.
1224 1240 pub async fn kill_domain(
1241 + Extension(claims): Extension<Claims>,
1225 1242 State(db): State<DatabaseConnection>,
1226 1243 session: Session,
1227 1244 Form(form): Form<KillDomainForm>,
1228 1245 ) -> Result<axum::response::Response, AppError> {
1229 1246 csrf::validate_csrf_token(&session, &form.csrf_token).await?;
1247 + // ANSSI FUI-AUTH-1 — audit trail for whole-domain disablement.
1248 + tracing::info!(
1249 + admin_id = %claims.sub,
1250 + domain = %form.domain,
1251 + action = "kill_domain",
1252 + "admin destructive fediverse action",
1253 + );
1230 1254
1231 1255 let trust = FediverseTrustService::new(db.clone());
1232 1256 if !trust.is_enabled().await? {
@@ -1709,12 +1733,21 @@ pub async fn create_team_invite(
1709 1733
1710 1734 /// `POST /admin/fediverse/invites/{team_id}/revoke` — revoke an invite.
1711 1735 pub async fn revoke_team_invite(
1736 + Extension(claims): Extension<Claims>,
1712 1737 State(db): State<DatabaseConnection>,
1713 1738 session: Session,
1714 1739 axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>,
1715 1740 Form(form): Form<RevokeInviteForm>,
1716 1741 ) -> Result<axum::response::Response, AppError> {
1717 1742 csrf::validate_csrf_token(&session, &form.csrf_token).await?;
1743 + // ANSSI FUI-AUTH-1 — audit trail for team invite revocation.
1744 + tracing::info!(
1745 + admin_id = %claims.sub,
1746 + team_id = %team_id,
1747 + invite_id = %form.id,
1748 + action = "revoke_team_invite",
1749 + "admin destructive fediverse action",
1750 + );
1718 1751
1719 1752 let trust = FediverseTrustService::new(db.clone());
1720 1753 if !trust.is_enabled().await? {
@@ -2016,3 +2049,65 @@ pub async fn get_teams_for_invites(
2016 2049
2017 2050 Ok(template.into_response())
2018 2051 }
2052 +
2053 +// ─── ANSSI FUI-TEST-1 — scaffold ────────────────────────────────────
2054 +//
2055 +// Initial coverage focused on the pure-Rust helpers exercised by the
2056 +// admin handlers (truncation, allow-listed config values). The full
2057 +// integration matrix (CSRF rejection × auth absence × happy path for
2058 +// the 8 POST handlers) is a follow-up that requires a
2059 +// `tower-sessions::MemoryStore` + `SeaORM MockDatabase` harness and
2060 +// is tracked in docs/ANSSI_FEDIVERSE_COMPLIANCE.md → FUI-TEST-1.
2061 +#[cfg(test)]
2062 +mod tests {
2063 + use super::*;
2064 +
2065 + #[test]
2066 + fn truncate_returns_original_when_shorter_than_max() {
2067 + assert_eq!(truncate("hello", 10), "hello");
2068 + }
2069 +
2070 + #[test]
2071 + fn truncate_returns_original_when_equal_to_max() {
2072 + assert_eq!(truncate("hello", 5), "hello");
2073 + }
2074 +
2075 + #[test]
2076 + fn truncate_appends_ellipsis_when_cut() {
2077 + let out = truncate("hello world", 6);
2078 + assert!(out.ends_with('…'), "expected ellipsis suffix, got {out:?}");
2079 + assert_eq!(out.chars().count(), 6);
2080 + }
2081 +
2082 + #[test]
2083 + fn truncate_handles_multibyte_chars_without_panic() {
2084 + // Each emoji is several bytes — `truncate` must count chars, not bytes.
2085 + let out = truncate("🚀🚀🚀🚀🚀", 3);
2086 + assert_eq!(out.chars().count(), 3);
2087 + assert!(out.ends_with('…'));
2088 + }
2089 +
2090 + #[test]
2091 + fn trust_modes_contains_canonical_values() {
2092 + assert!(TRUST_MODES.contains(&"allowlist"));
2093 + assert!(TRUST_MODES.contains(&"open_with_blocklist"));
2094 + }
2095 +
2096 + #[test]
2097 + fn interop_scopes_is_non_empty() {
2098 + assert!(!INTEROP_SCOPES.is_empty());
2099 + }
2100 +
2101 + #[test]
2102 + fn severities_covers_three_documented_levels() {
2103 + assert!(SEVERITIES.contains(&"suspend"));
2104 + assert!(SEVERITIES.contains(&"silence"));
2105 + assert!(SEVERITIES.contains(&"reject_auth"));
2106 + assert_eq!(SEVERITIES.len(), 3);
2107 + }
2108 +
2109 + #[test]
2110 + fn invite_ttl_default_is_seven_days() {
2111 + assert_eq!(INVITE_TTL.as_secs(), 7 * 24 * 60 * 60);
2112 + }
2113 +}
M crates/rustwarden-fediverse-ui/src/routes.rs
+19 -2
@@ -5,12 +5,26 @@
5 5 //! that lived in `rustwarden-ui` so wiring stays a one-liner:
6 6 //! `builder.merge_routes(rustwarden_fediverse_ui::default_routes())`.
7 7
8 +use axum::extract::DefaultBodyLimit;
8 9 use axum::routing::{get, post};
9 10 use axum::Router;
10 11 use sea_orm::DatabaseConnection;
12 +use tower::limit::ConcurrencyLimitLayer;
11 13
12 14 use crate::handlers::{admin, user_auth};
13 15
16 +/// Maximum concurrent SSE connections to `/login/fediverse/sse/{id}`
17 +/// (ANSSI FUI-DOS-1). The endpoint is unauthenticated and holds a DB
18 +/// connection for up to 5 minutes per challenge — capping concurrency
19 +/// prevents pool exhaustion by a single attacker.
20 +const SSE_MAX_CONCURRENT: usize = 100;
21 +
22 +/// Maximum body size accepted on `POST /login/fediverse` (ANSSI
23 +/// FUI-NET-1). 8 KiB is far above any legitimate login form payload
24 +/// (handle field ~100 bytes + CSRF token ~64 bytes + a few hidden
25 +/// inputs) and bounds the DoS surface on this unauthenticated route.
26 +const LOGIN_FORM_BODY_LIMIT_BYTES: usize = 8 * 1024;
27 +
14 28 /// Returns the default federation UI routes (public, authenticated and
15 29 /// admin) wired against `DatabaseConnection` state.
16 30 pub fn default_routes() -> Router<DatabaseConnection> {
@@ -25,7 +39,9 @@ pub fn default_routes() -> Router<DatabaseConnection> {
25 39 .route("/login/fediverse/error", get(user_auth::error))
26 40 .route(
27 41 "/login/fediverse",
28 - get(user_auth::login_form).post(user_auth::login_initiate),
42 + get(user_auth::login_form)
43 + .post(user_auth::login_initiate)
44 + .layer(DefaultBodyLimit::max(LOGIN_FORM_BODY_LIMIT_BYTES)),
29 45 )
30 46 .route(
31 47 "/login/fediverse/waiting/{challenge_id}",
@@ -33,7 +49,8 @@ pub fn default_routes() -> Router<DatabaseConnection> {
33 49 )
34 50 .route(
35 51 "/login/fediverse/sse/{challenge_id}",
36 - get(user_auth::sse_status),
52 + get(user_auth::sse_status)
53 + .layer(ConcurrencyLimitLayer::new(SSE_MAX_CONCURRENT)),
37 54 )
38 55 // Authenticated routes (consent, invite, connected apps).
39 56 .route(
M crates/rustwarden-fediverse-ui/templates/login_waiting.html
+8 -57
@@ -18,62 +18,13 @@
18 18 </div>
19 19 </main>
20 20
21 - <script>
22 - // RFC 0023 Slice B iter. 3 — Server-Sent Events listener.
23 - // Pas de framework JS lourd : EventSource natif suffit.
24 - (function() {
25 - const challengeId = "{{ challenge_id }}";
26 - const sse = new EventSource("/login/fediverse/sse/" + challengeId);
27 -
28 - sse.addEventListener("status", function(ev) {
29 - let payload;
30 - try {
31 - payload = JSON.parse(ev.data);
32 - } catch (_) {
33 - return;
34 - }
35 - const state = payload.state;
36 - if (state === "approved") {
37 - // L'IdP a approuvé : appeler /api/v1/auth/login/fediverse/consume/{id}
38 - // qui pose le cookie JWT remote, puis redirection /home.
39 - fetch("/api/v1/auth/login/fediverse/consume/" + challengeId, {
40 - method: "POST",
41 - credentials: "same-origin",
42 - })
43 - .then(function(r) {
44 - if (r.ok) {
45 - window.location.href = "/home";
46 - } else {
47 - window.location.href = "/login/fediverse/error?reason=unknown";
48 - }
49 - })
50 - .catch(function() {
51 - window.location.href = "/login/fediverse/error?reason=unknown";
52 - });
53 - sse.close();
54 - } else if (state === "rejected") {
55 - sse.close();
56 - window.location.href = "/login/fediverse/error?reason=rejected";
57 - } else if (state === "expired") {
58 - sse.close();
59 - window.location.href = "/login/fediverse/error?reason=timeout";
60 - } else if (state === "consumed") {
61 - sse.close();
62 - window.location.href = "/home";
63 - }
64 - // state === "pending" → rien à faire, on attend le prochain poll.
65 - });
66 -
67 - sse.onerror = function() {
68 - // Connexion perdue ; le serveur va relancer mais on
69 - // protège l'UX si le navigateur abandonne après plusieurs
70 - // tentatives.
71 - sse.close();
72 - setTimeout(function() {
73 - window.location.href = "/login/fediverse/error?reason=timeout";
74 - }, 30000);
75 - };
76 - })();
77 - </script>
21 + {# ANSSI FUI-CSP-1 — script externalised so CSP can drop #}
22 + {# `script-src 'unsafe-inline'` for this template. The challenge_id #}
23 + {# travels via a `data-*` attribute, not interpolated into JS code. #}
24 + <script
25 + id="fediverse-sse-script"
26 + src="/static/js/fediverse-sse.js"
27 + data-challenge-id="{{ challenge_id }}"
28 + defer></script>
78 29 </body>
79 30 </html>
M crates/rustwarden-fediverse/migration/src/m20260512_000001_fediverse_protocol_schema.rs
+1 -1
@@ -121,7 +121,7 @@ impl MigrationTrait for Migration {
121 121 Uuid::new_v4().into(),
122 122 key.into(),
123 123 value.into(),
124 - Expr::current_timestamp().into(),
124 + Expr::current_timestamp(),
125 125 ])
126 126 .on_conflict(
127 127 OnConflict::column(AppSettings::Key)
M crates/rustwarden-fediverse/src/auth_routes.rs
+50 -0
@@ -25,6 +25,8 @@ use serde::{Deserialize, Serialize};
25 25 use uuid::Uuid;
26 26
27 27 use super::error::FediverseError;
28 +use super::state::FediverseState;
29 +use rustwarden_core::auth::jwt::Claims;
28 30 use rustwarden_core::config::auth::AuthConfig;
29 31 use crate::services::fediverse_auth_service::{
30 32 ChallengeDecision, FediverseAuthService, InitiateRequest,
@@ -100,13 +102,49 @@ pub struct ConsentBody {
100 102 pub remote_identity_id: Option<Uuid>,
101 103 }
102 104
105 +/// Validate a user-supplied redirect URL against the local instance
106 +/// domain (ANSSI F-NET-2).
107 +///
108 +/// Accepts only `http://` or `https://` schemes and rejects any host
109 +/// that does not match `allowed_host` (case-insensitive). This blocks
110 +/// open-redirect attacks (`javascript:`, `data:`, cross-domain
111 +/// phishing) before the value is persisted into
112 +/// `fediverse_auth_challenges.redirect_url`.
113 +fn validate_redirect_url(url_str: &str, allowed_host: &str) -> Result<(), FediverseError> {
114 + let parsed = url::Url::parse(url_str).map_err(|e| {
115 + FediverseError::BadRequest(format!("invalid redirect_url: {e}"))
116 + })?;
117 + match parsed.scheme() {
118 + "http" | "https" => {}
119 + other => {
120 + return Err(FediverseError::BadRequest(format!(
121 + "redirect_url scheme {other:?} not allowed (use http or https)"
122 + )));
123 + }
124 + }
125 + let host = parsed.host_str().unwrap_or("").to_ascii_lowercase();
126 + if host != allowed_host.to_ascii_lowercase() {
127 + return Err(FediverseError::BadRequest(format!(
128 + "redirect_url host {host:?} differs from this instance domain"
129 + )));
130 + }
131 + Ok(())
132 +}
133 +
103 134 // ─── Handlers ────────────────────────────────────────────────────────
104 135
105 136 async fn initiate(
106 137 Extension(state): Extension<AuthFlowState>,
138 + Extension(fediverse_state): Extension<FediverseState>,
107 139 Extension(db): Extension<DatabaseConnection>,
108 140 Json(body): Json<InitiateBody>,
109 141 ) -> Result<(StatusCode, Json<InitiateResponse>), FediverseError> {
142 + // ANSSI F-NET-2 — reject arbitrary `javascript:`, `data:`, or
143 + // cross-domain redirect URLs before they hit the database. The
144 + // accepted form is `http(s)://<this instance domain>/...`.
145 + if let Some(redirect) = body.redirect_url.as_deref() {
146 + validate_redirect_url(redirect, fediverse_state.config.domain())?;
147 + }
110 148 let challenge = FediverseAuthService::initiate(
111 149 &db,
112 150 InitiateRequest {
@@ -251,9 +289,21 @@ async fn auth_inbox(
251 289
252 290 async fn consent(
253 291 Extension(_state): Extension<AuthFlowState>,
292 + Extension(claims): Extension<Claims>,
254 293 Extension(db): Extension<DatabaseConnection>,
255 294 Json(body): Json<ConsentBody>,
256 295 ) -> Result<StatusCode, FediverseError> {
296 + // ANSSI F-NET-4 — require an authenticated Bearer JWT on this
297 + // JSON-API endpoint. Cookie-only callers cannot reach it, which
298 + // structurally eliminates the CSRF surface on this consent flow
299 + // (the UI counterpart in rustwarden-fediverse-ui already enforces
300 + // CSRF via `csrf::validate_csrf_token`).
301 + tracing::info!(
302 + user_id = %claims.sub,
303 + challenge_id = %body.challenge_id,
304 + decision = %body.decision,
305 + "fediverse consent recorded (api)",
306 + );
257 307 let decision = match body.decision.as_str() {
258 308 "approve" => {
259 309 let remote = body.remote_identity_id.ok_or_else(|| {
M crates/rustwarden-fediverse/src/http_signatures.rs
+172 -13
@@ -165,11 +165,89 @@ pub fn parse_and_verify_cavage(
165 165 parsed.algorithm,
166 166 )));
167 167 }
168 +
169 + // RFC 0015 / ANSSI F-CRYPTO-2 — when the `date` header is part of
170 + // the signature coverage, reject signatures whose declared time
171 + // drifts by more than `MAX_DATE_SKEW_SECONDS` from the local UTC
172 + // clock. This blocks indefinite replay of captured signed
173 + // activities (Delete, Update, Revoke, …).
174 + if parsed.headers.iter().any(|h| h == "date") {
175 + let date_value = headers("date").ok_or_else(|| {
176 + AppError::Validation(
177 + "Signature covers 'date' but the Date header is absent".into(),
178 + )
179 + })?;
180 + validate_date_header(&date_value, MAX_DATE_SKEW_SECONDS)?;
181 + }
182 +
183 + // RFC 0015 / ANSSI F-CRYPTO-1 — when the `digest` header is part
184 + // of the signature coverage, verify that it matches the SHA-256 of
185 + // the actual body. Without this, an attacker can substitute the
186 + // body while keeping the signed headers intact (MITM / log replay).
187 + if parsed.headers.iter().any(|h| h == "digest") {
188 + let digest_value = headers("digest").ok_or_else(|| {
189 + AppError::Validation(
190 + "Signature covers 'digest' but the Digest header is absent".into(),
191 + )
192 + })?;
193 + validate_digest(&digest_value, body)?;
194 + }
195 +
168 196 let input = build_signature_input(&parsed.headers, request_target, headers)?;
169 - let _ = body; // body is hashed via the `digest` header by Mastodon when present.
170 197 rsa_sha256_verify(public_pem, input.as_bytes(), &parsed.signature)
171 198 }
172 199
200 +/// Default tolerance for the signed `Date` header (5 minutes,
201 +/// Mastodon/Pleroma/Misskey-compatible).
202 +const MAX_DATE_SKEW_SECONDS: i64 = 300;
203 +
204 +/// Verify that a signed `Date` header is within ±`skew_seconds` of the
205 +/// current UTC time (RFC 0015 — replay protection).
206 +///
207 +/// Returns `Err(AppError::Validation(_))` if the value cannot be parsed
208 +/// as RFC 2822 or if the absolute drift exceeds `skew_seconds`.
209 +fn validate_date_header(date_str: &str, skew_seconds: i64) -> Result<(), AppError> {
210 + let parsed = chrono::DateTime::parse_from_rfc2822(date_str.trim())
211 + .map_err(|e| AppError::Validation(format!("invalid Date header: {e}")))?;
212 + let diff = (chrono::Utc::now() - parsed.with_timezone(&chrono::Utc))
213 + .num_seconds()
214 + .abs();
215 + if diff > skew_seconds {
216 + return Err(AppError::Validation(format!(
217 + "Date header out of acceptable skew window ({diff}s > {skew_seconds}s)"
218 + )));
219 + }
220 + Ok(())
221 +}
222 +
223 +/// Verify that a signed `Digest` header matches `SHA-256(body)` in
224 +/// constant time (RFC 0015 — body integrity).
225 +///
226 +/// Accepted format: `SHA-256=<base64>` (Mastodon canonical). Rejects
227 +/// any other algorithm, malformed base64, or hash mismatch. Uses
228 +/// `subtle::ConstantTimeEq` to avoid timing side-channels.
229 +fn validate_digest(digest_header: &str, body: &[u8]) -> Result<(), AppError> {
230 + use sha2::Digest;
231 + use subtle::ConstantTimeEq;
232 +
233 + let (algo, expected_b64) = digest_header
234 + .split_once('=')
235 + .ok_or_else(|| AppError::Validation("malformed Digest header".into()))?;
236 + if !algo.eq_ignore_ascii_case("SHA-256") {
237 + return Err(AppError::Validation(format!(
238 + "unsupported Digest algorithm: {algo}"
239 + )));
240 + }
241 + let expected = base64::engine::general_purpose::STANDARD
242 + .decode(expected_b64.trim())
243 + .map_err(|e| AppError::Validation(format!("Digest base64 decode: {e}")))?;
244 + let actual = Sha256::digest(body);
245 + if actual.as_slice().ct_eq(expected.as_slice()).unwrap_u8() == 0 {
246 + return Err(AppError::Validation("body digest mismatch".into()));
247 + }
248 + Ok(())
249 +}
250 +
173 251 /// Production-grade implementation of [`SignatureVerifier`] (RFC 0016
174 252 /// + RFC 0017).
175 253 ///
@@ -431,9 +509,13 @@ mod tests {
431 509 "host".to_string(),
432 510 "date".to_string(),
433 511 ];
512 + // F-CRYPTO-2: use a fresh `Date` so the skew validator accepts it.
513 + let date = chrono::Utc::now()
514 + .format("%a, %d %b %Y %H:%M:%S GMT")
515 + .to_string();
434 516 let input = build_signature_input(&signed_headers, "post /inbox", |h| match h {
435 517 "host" => Some("b.tld".into()),
436 - "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()),
518 + "date" => Some(date.clone()),
437 519 _ => None,
438 520 })
439 521 .unwrap();
@@ -444,7 +526,7 @@ mod tests {
444 526 );
445 527 parse_and_verify_cavage(&header, "post /inbox", b"", &pub_pem, |h| match h {
446 528 "host" => Some("b.tld".into()),
447 - "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()),
529 + "date" => Some(date.clone()),
448 530 _ => None,
449 531 })
450 532 .expect("verify ok");
@@ -510,10 +592,13 @@ mod tests {
510 592 "host".to_string(),
511 593 "date".to_string(),
512 594 ];
595 + let date = chrono::Utc::now()
596 + .format("%a, %d %b %Y %H:%M:%S GMT")
597 + .to_string();
513 598 let input =
514 599 build_signature_input(&signed_headers, "post /inbox", |h| match h {
515 600 "host" => Some("b.tld".into()),
516 - "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()),
601 + "date" => Some(date.clone()),
517 602 _ => None,
518 603 })
519 604 .unwrap();
@@ -525,10 +610,7 @@ mod tests {
525 610 let mut headers = HeaderMap::new();
526 611 headers.insert("signature", HeaderValue::from_str(&header_value).unwrap());
527 612 headers.insert("host", HeaderValue::from_static("b.tld"));
528 - headers.insert(
529 - "date",
530 - HeaderValue::from_static("Mon, 10 May 2026 12:00:00 GMT"),
531 - );
613 + headers.insert("date", HeaderValue::from_str(&date).unwrap());
532 614 let body = b"";
533 615 let ctx = VerifyCtx {
534 616 actor_ap_id: "x",
@@ -557,10 +639,13 @@ mod tests {
557 639 "host".to_string(),
558 640 "date".to_string(),
559 641 ];
642 + let date = chrono::Utc::now()
643 + .format("%a, %d %b %Y %H:%M:%S GMT")
644 + .to_string();
560 645 let input =
561 646 build_signature_input(&signed_headers, "post /inbox", |h| match h {
562 647 "host" => Some("b.tld".into()),
563 - "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()),
648 + "date" => Some(date.clone()),
564 649 _ => None,
565 650 })
566 651 .unwrap();
@@ -572,10 +657,7 @@ mod tests {
572 657 let mut headers = HeaderMap::new();
573 658 headers.insert("signature", HeaderValue::from_str(&header_value).unwrap());
574 659 headers.insert("host", HeaderValue::from_static("b.tld"));
575 - headers.insert(
576 - "date",
577 - HeaderValue::from_static("Mon, 10 May 2026 12:00:00 GMT"),
578 - );
660 + headers.insert("date", HeaderValue::from_str(&date).unwrap());
579 661
580 662 let now = Utc::now();
581 663 let cached = crate::models::remote_identity::Model {
@@ -700,4 +782,81 @@ mod tests {
700 782 .expect_err("must reject tampered host");
701 783 assert!(matches!(err, AppError::Internal(_)));
702 784 }
785 +
786 + // ─── ANSSI F-CRYPTO-1 — body digest validation ─────────────────
787 +
788 + #[test]
789 + fn validate_digest_accepts_matching_sha256() {
790 + use sha2::Digest;
791 + let body = b"hello world";
792 + let expected = base64::engine::general_purpose::STANDARD
793 + .encode(sha2::Sha256::digest(body));
794 + let header = format!("SHA-256={expected}");
795 + validate_digest(&header, body).expect("matching digest must succeed");
796 + }
797 +
798 + #[test]
799 + fn validate_digest_rejects_mismatched_body() {
800 + use sha2::Digest;
801 + let header = format!(
802 + "SHA-256={}",
803 + base64::engine::general_purpose::STANDARD
804 + .encode(sha2::Sha256::digest(b"signed body"))
805 + );
806 + let err = validate_digest(&header, b"tampered body")
807 + .expect_err("mismatched digest must be rejected");
808 + assert!(format!("{err}").contains("mismatch"));
809 + }
810 +
811 + #[test]
812 + fn validate_digest_rejects_unsupported_algorithm() {
813 + let err = validate_digest("MD5=abc", b"")
814 + .expect_err("unsupported algorithm must be rejected");
815 + assert!(format!("{err}").contains("unsupported"));
816 + }
817 +
818 + #[test]
819 + fn validate_digest_rejects_malformed_header() {
820 + let err = validate_digest("no-equals-sign", b"")
821 + .expect_err("malformed digest must be rejected");
822 + assert!(format!("{err}").contains("malformed"));
823 + }
824 +
825 + // ─── ANSSI F-CRYPTO-2 — Date header skew validation ────────────
826 +
827 + #[test]
828 + fn validate_date_header_accepts_recent_timestamp() {
829 + let now = chrono::Utc::now()
830 + .format("%a, %d %b %Y %H:%M:%S GMT")
831 + .to_string();
832 + validate_date_header(&now, MAX_DATE_SKEW_SECONDS)
833 + .expect("recent date must succeed");
834 + }
835 +
836 + #[test]
837 + fn validate_date_header_rejects_stale_timestamp() {
838 + let stale = (chrono::Utc::now() - chrono::Duration::hours(2))
839 + .format("%a, %d %b %Y %H:%M:%S GMT")
840 + .to_string();
841 + let err = validate_date_header(&stale, MAX_DATE_SKEW_SECONDS)
842 + .expect_err("stale date must be rejected");
843 + assert!(format!("{err}").contains("skew"));
844 + }
845 +
846 + #[test]
847 + fn validate_date_header_rejects_future_timestamp() {
848 + let future = (chrono::Utc::now() + chrono::Duration::hours(2))
849 + .format("%a, %d %b %Y %H:%M:%S GMT")
850 + .to_string();
851 + let err = validate_date_header(&future, MAX_DATE_SKEW_SECONDS)
852 + .expect_err("future date must be rejected");
853 + assert!(format!("{err}").contains("skew"));
854 + }
855 +
856 + #[test]
857 + fn validate_date_header_rejects_malformed_string() {
858 + let err = validate_date_header("not a date", MAX_DATE_SKEW_SECONDS)
859 + .expect_err("malformed date must be rejected");
860 + assert!(format!("{err}").contains("invalid"));
861 + }
703 862 }
M crates/rustwarden-fediverse/src/invite_routes.rs
+59 -18
@@ -14,7 +14,6 @@
14 14 //! - `POST /api/v1/fediverse/invites/{invite_id}/reject`
15 15 //!
16 16
17 -use std::sync::Arc;
18 17 use std::time::Duration;
19 18
20 19 use axum::{
@@ -28,6 +27,7 @@ use serde::{Deserialize, Serialize};
28 27 use uuid::Uuid;
29 28
30 29 use super::error::FediverseError;
30 +use rustwarden_core::auth::jwt::Claims;
31 31 use rustwarden_core::error::AppError;
32 32 use crate::services::fediverse_invite_service::FediverseInviteService;
33 33 use rustwarden_core::services::team_service::TeamRole;
@@ -39,12 +39,6 @@ pub struct InviteRoutesState {
39 39 /// when the consumer does not override (set by
40 40 /// [`crate::routes::router_with_auth`]).
41 41 pub default_ttl: Duration,
42 - /// FK of the user the invite is recorded as having been sent
43 - /// **by**. In production the consumer overrides this with the
44 - /// authenticated admin's `users.id`; the placeholder makes the
45 - /// minimal RFC 0014 slice usable end-to-end without forcing
46 - /// every consumer to wire JWT extraction immediately.
47 - pub system_actor_user_id: Arc<dyn Fn() -> Uuid + Send + Sync>,
48 42 }
49 43
50 44 impl std::fmt::Debug for InviteRoutesState {
@@ -107,21 +101,23 @@ pub struct IdpListQuery {
107 101 async fn invite(
108 102 Extension(state): Extension<InviteRoutesState>,
109 103 Extension(db): Extension<DatabaseConnection>,
110 - claims: Option<Extension<rustwarden_core::auth::jwt::Claims>>,
104 + Extension(claims): Extension<Claims>,
111 105 Path(team_id): Path<Uuid>,
112 106 Json(body): Json<InviteBody>,
113 107 ) -> Result<(StatusCode, Json<InviteResponse>), FediverseError> {
114 108 let role = TeamRole::parse(&body.role)
115 109 .ok_or_else(|| FediverseError::BadRequest(format!("invalid role: {}", body.role)))?;
116 - // Prefer the authenticated admin's id (injected by AuthService for
117 - // `/api/v1/teams/*` since iter 4 hygiene). Fall back to the
118 - // configured placeholder only when no JWT was present — that path
119 - // remains for unit tests with `MockDatabase` and seed-style flows
120 - // that don't go through the auth middleware.
121 - let inviter = match claims {
122 - Some(Extension(c)) => c.sub,
123 - None => (state.system_actor_user_id)(),
124 - };
110 + // ANSSI F-NET-3 — Claims are now mandatory (Axum returns 401 when
111 + // the JWT middleware did not inject them). The inviter is always
112 + // the authenticated admin's user id.
113 + let inviter = claims.sub;
114 + tracing::info!(
115 + admin_id = %inviter,
116 + team_id = %team_id,
117 + handle = %body.handle,
118 + role = %body.role,
119 + "fediverse invite issued",
120 + );
125 121 let model = FediverseInviteService::invite_remote(
126 122 &db,
127 123 team_id,
@@ -143,24 +139,37 @@ async fn invite(
143 139
144 140 async fn list_for_team(
145 141 Extension(db): Extension<DatabaseConnection>,
142 + Extension(_claims): Extension<Claims>,
146 143 Path(team_id): Path<Uuid>,
147 144 ) -> Result<Json<Vec<InviteRow>>, FediverseError> {
145 + // ANSSI F-NET-3 — Claims are mandatory; future RFC will add RBAC
146 + // membership check before listing.
148 147 let rows = FediverseInviteService::list_pending_for_team(&db, team_id).await?;
149 148 Ok(Json(rows.into_iter().map(into_row).collect()))
150 149 }
151 150
152 151 async fn revoke(
153 152 Extension(db): Extension<DatabaseConnection>,
153 + Extension(claims): Extension<Claims>,
154 154 Path((_team_id, invite_id)): Path<(Uuid, Uuid)>,
155 155 ) -> Result<Json<InviteRow>, FediverseError> {
156 + // ANSSI F-NET-3 / FUI-AUTH-1 — audit-trail destructive action.
157 + tracing::info!(
158 + admin_id = %claims.sub,
159 + invite_id = %invite_id,
160 + "fediverse invite revoked",
161 + );
156 162 let model = FediverseInviteService::revoke(&db, invite_id).await?;
157 163 Ok(Json(into_row(model)))
158 164 }
159 165
160 166 async fn idp_list(
161 167 Extension(db): Extension<DatabaseConnection>,
168 + Extension(_claims): Extension<Claims>,
162 169 Query(query): Query<IdpListQuery>,
163 170 ) -> Result<Json<Vec<InviteRow>>, FediverseError> {
171 + // ANSSI F-NET-3 — IdP-side listing is now authenticated; future RFC
172 + // will narrow `handle` to the caller's own bound identity.
164 173 let rows =
165 174 FediverseInviteService::list_pending_for_handle(&db, &query.handle).await?;
166 175 Ok(Json(rows.into_iter().map(into_row).collect()))
@@ -177,17 +186,33 @@ struct AcceptBody {
177 186
178 187 async fn idp_accept(
179 188 Extension(db): Extension<DatabaseConnection>,
189 + Extension(claims): Extension<Claims>,
180 190 Path(invite_id): Path<Uuid>,
181 191 Json(body): Json<AcceptBody>,
182 192 ) -> Result<StatusCode, FediverseError> {
193 + // ANSSI F-NET-3 — authenticated user accepts the invite under
194 + // their own JWT identity (audit trail).
195 + tracing::info!(
196 + user_id = %claims.sub,
197 + invite_id = %invite_id,
198 + remote_identity_id = %body.remote_identity_id,
199 + "fediverse invite accepted (idp-side)",
200 + );
183 201 FediverseInviteService::accept(&db, invite_id, body.remote_identity_id).await?;
184 202 Ok(StatusCode::OK)
185 203 }
186 204
187 205 async fn idp_reject(
188 206 Extension(db): Extension<DatabaseConnection>,
207 + Extension(claims): Extension<Claims>,
189 208 Path(invite_id): Path<Uuid>,
190 209 ) -> Result<StatusCode, FediverseError> {
210 + // ANSSI F-NET-3 — authenticated user rejects the invite.
211 + tracing::info!(
212 + user_id = %claims.sub,
213 + invite_id = %invite_id,
214 + "fediverse invite rejected (idp-side)",
215 + );
191 216 FediverseInviteService::reject(&db, invite_id).await?;
192 217 Ok(StatusCode::OK)
193 218 }
@@ -261,13 +286,29 @@ mod tests {
261 286 }
262 287 }
263 288
289 + fn test_claims() -> rustwarden_core::auth::jwt::Claims {
290 + rustwarden_core::auth::jwt::Claims {
291 + sub: Uuid::new_v4(),
292 + jti: "test-jti".into(),
293 + aud: "test".into(),
294 + username: "admin".into(),
295 + roles: vec!["admin".into()],
296 + permissions: vec![],
297 + exp: i64::MAX / 2,
298 + iat: 0,
299 + iss: "rustwarden-test".into(),
300 + identity_kind: rustwarden_core::auth::jwt::IdentityKind::Local,
301 + remote_id: None,
302 + }
303 + }
304 +
264 305 fn build_router(db: DatabaseConnection) -> Router {
265 306 let state = InviteRoutesState {
266 307 default_ttl: Duration::from_secs(7 * 86400),
267 - system_actor_user_id: Arc::new(Uuid::new_v4),
268 308 };
269 309 router(state)
270 310 .layer(Extension(db))
311 + .layer(Extension(test_claims()))
271 312 .with_state(MockDatabase::new(DatabaseBackend::Postgres).into_connection())
272 313 }
273 314
M crates/rustwarden-fediverse/src/routes.rs
+34 -26
@@ -8,10 +8,18 @@ use std::sync::Arc;
8 8
9 9 use axum::{
10 10 Extension, Router,
11 + extract::DefaultBodyLimit,
11 12 routing::{get, post},
12 13 };
13 14 use sea_orm::DatabaseConnection;
14 15
16 +/// Maximum inbound activity body size accepted on `/inbox` (ANSSI F-NET-1).
17 +///
18 +/// 1 MiB matches Mastodon's hard-coded ceiling for inbox activities.
19 +/// Anything larger is rejected with 413 before deserialization, capping
20 +/// the DoS surface from oversized JSON-LD payloads.
21 +const INBOX_BODY_LIMIT_BYTES: usize = 1024 * 1024;
22 +
15 23 use crate::config::FediverseConfig;
16 24 use rustwarden_core::config::auth::AuthConfig;
17 25 use crate::services::fediverse_signal_service::{AlwaysAcceptVerifier, SignatureVerifier};
@@ -27,6 +35,15 @@ use super::webfinger;
27 35
28 36 /// Build the federation router with the default SeaORM-backed resolver
29 37 /// and the test-only [`AlwaysAcceptVerifier`].
38 +///
39 +/// **DEV/TEST ONLY** — ANSSI F-NET-5: this entry point uses
40 +/// `AlwaysAcceptVerifier` which accepts any HTTP signature without
41 +/// verifying it. Production callers must use
42 +/// [`router_with_auth`] with a real verifier (`HttpSigVerifier`).
43 +#[deprecated(
44 + since = "0.2.0",
45 + note = "use `router_with_auth` with an explicit `AuthConfig` and a production `HttpSigVerifier`"
46 +)]
30 47 pub fn router(config: FediverseConfig, db: DatabaseConnection) -> Router<DatabaseConnection> {
31 48 let resolver: Arc<dyn LocalUserResolver> = Arc::new(SeaOrmLocalUserResolver::new(db.clone()));
32 49 // Debug/dev verifier with cache-population side-effect so the
@@ -50,8 +67,16 @@ pub fn router_with(
50 67 let inbox_state = InboxState {
51 68 verifier: verifier.clone(),
52 69 };
70 + // ANSSI F-NET-5 — fail loudly when env vars are missing rather
71 + // than silently falling back to a placeholder JWT secret. Production
72 + // callers should prefer `router_with_auth` which takes
73 + // `AuthConfig` explicitly and lets the caller surface configuration
74 + // errors as proper startup failures.
53 75 let auth_flow_state = AuthFlowState {
54 - auth_config: Arc::new(AuthConfig::from_env().unwrap_or_else(|_| placeholder_auth_config())),
76 + auth_config: Arc::new(AuthConfig::from_env().expect(
77 + "AuthConfig::from_env() failed — set JWT_SECRET and related env vars, \
78 + or call `router_with_auth` with an explicit AuthConfig",
79 + )),
55 80 verifier,
56 81 challenge_ttl,
57 82 };
@@ -60,11 +85,13 @@ pub fn router_with(
60 85 .route("/.well-known/nodeinfo", get(nodeinfo::discovery))
61 86 .route("/nodeinfo/2.1", get(nodeinfo::detail))
62 87 .route("/users/{username}", get(actor::handle))
63 - .route("/inbox", post(inbox::handle))
88 + .route(
89 + "/inbox",
90 + post(inbox::handle).layer(DefaultBodyLimit::max(INBOX_BODY_LIMIT_BYTES)),
91 + )
64 92 .merge(auth_routes::router(auth_flow_state))
65 93 .merge(invite_routes::router(InviteRoutesState {
66 94 default_ttl: std::time::Duration::from_secs(7 * 86400),
67 - system_actor_user_id: Arc::new(uuid::Uuid::new_v4),
68 95 }))
69 96 .layer(Extension(state))
70 97 .layer(Extension(inbox_state))
@@ -94,37 +121,18 @@ pub fn router_with_auth(
94 121 .route("/.well-known/nodeinfo", get(nodeinfo::discovery))
95 122 .route("/nodeinfo/2.1", get(nodeinfo::detail))
96 123 .route("/users/{username}", get(actor::handle))
97 - .route("/inbox", post(inbox::handle))
124 + .route(
125 + "/inbox",
126 + post(inbox::handle).layer(DefaultBodyLimit::max(INBOX_BODY_LIMIT_BYTES)),
127 + )
98 128 .merge(auth_routes::router(auth_flow_state))
99 129 .merge(invite_routes::router(InviteRoutesState {
100 130 default_ttl: std::time::Duration::from_secs(7 * 86400),
101 - system_actor_user_id: Arc::new(uuid::Uuid::new_v4),
102 131 }))
103 132 .layer(Extension(state))
104 133 .layer(Extension(inbox_state))
105 134 }
106 135
107 -fn placeholder_auth_config() -> AuthConfig {
108 - // Env vars not set at router-build time — use harmless defaults.
109 - // Production callers should always go through the federation router builder
110 - // which loads `AuthConfig` once at app startup.
111 - AuthConfig {
112 - jwt_secret: "0".repeat(64),
113 - jwt_expiration_minutes: 15,
114 - jwt_issuer: "rustwarden".to_string(),
115 - refresh_token_expiration_days: 7,
116 - remember_me_expiration_days: 30,
117 - session_timeout_minutes: 30,
118 - session_backend: rustwarden_core::config::auth::SessionBackend::Memory,
119 - redis_url: None,
120 - rate_limit_login_per_minute: 5,
121 - rate_limit_refresh_per_minute: 10,
122 - rate_limit_general_per_minute: 100,
123 - cookie_secure: false,
124 - cookie_same_site: "Strict".to_string(),
125 - }
126 -}
127 -
128 136 /// Backwards-compatible alias for [`router_with`] kept until the next
129 137 /// major version; new code should call `router_with` directly.
130 138 #[deprecated(since = "0.2.0", note = "use `router_with` (RFC 0012)")]
M crates/rustwarden-fediverse/tests/e2e_fediverse.rs
+7 -0
@@ -318,8 +318,12 @@ async fn joanie_beta_keys(client: &Client) -> JoanieBeta {
318 318 assert!(encrypted.starts_with("enc:"));
319 319
320 320 let prev = std::env::var("ENCRYPTION_KEY").ok();
321 + // SAFETY: e2e tests run sequentially against the docker-compose
322 + // testbed and this scope swaps `ENCRYPTION_KEY` only to decrypt a
323 + // single payload. The previous value is restored a few lines below.
321 324 unsafe { std::env::set_var("ENCRYPTION_KEY", BETA_ENCRYPTION_KEY) };
322 325 let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt must succeed");
326 + // SAFETY: restore the original `ENCRYPTION_KEY` for the next test.
323 327 unsafe {
324 328 match prev {
325 329 Some(v) => std::env::set_var("ENCRYPTION_KEY", v),
@@ -474,8 +478,11 @@ async fn joanie_alpha_keys(client: &Client) -> JoanieBeta {
474 478 assert!(encrypted.starts_with("enc:"));
475 479
476 480 let prev = std::env::var("ENCRYPTION_KEY").ok();
481 + // SAFETY: same rationale as above — e2e tests are serialised, the
482 + // env mutation is scoped to a single decryption call.
477 483 unsafe { std::env::set_var("ENCRYPTION_KEY", ALPHA_ENCRYPTION_KEY) };
478 484 let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt alpha key must succeed");
485 + // SAFETY: restore the original `ENCRYPTION_KEY` for the next test.
479 486 unsafe {
480 487 match prev {
481 488 Some(v) => std::env::set_var("ENCRYPTION_KEY", v),
M docs/ANSSI_FEDIVERSE_COMPLIANCE.md
+341 -346
@@ -1,28 +1,35 @@
1 -# Rapport de Conformité ANSSI — `rustwarden-fediverse`
1 +# Rapport de Conformité ANSSI — Domaine Fediverse
2 2
3 -> **Date** : 2026-05-13
4 -> **Crate** : `rustwarden-fediverse` 0.1.0
3 +> **Date** : 2026-05-14
4 +> **Portée** : `crates/rustwarden-fediverse/` 0.1.0 (35 fichiers `.rs`, ~11 958 lignes) + `crates/rustwarden-fediverse-ui/` 0.1.0 (9 fichiers `.rs`, 3 329 lignes + 15 templates HTML, 1 347 lignes)
5 5 > **Référence** : [Guide ANSSI Rust](https://anssi-fr.github.io/rust-guide/)
6 -> **Portée** : `crates/rustwarden-fediverse/` uniquement (35 fichiers `.rs`)
7 -> **Méthode** : audit statique + `cargo audit` (606 deps, advisory-db 1080 advisories) + `cargo clippy --all-targets -- -W clippy::pedantic`
6 +> **Méthode** : audit statique + `cargo audit` (607 deps, advisory-db 1 088 advisories) + `cargo clippy --all-targets -- -W clippy::pedantic`
7 +> **Rapport précédent** : 2026-05-13 (portée : `rustwarden-fediverse` uniquement, 9 findings, score 80%)
8 8
9 9 ## Résumé Exécutif
10 10
11 11 ### Score Global de Conformité
12 12
13 -- ✅ **Conforme** : 80%
14 -- ⚠️ **Améliorations recommandées** : 15%
15 -- ❌ **Non conforme** : 5%
13 +- ✅ **Conforme** : 70%
14 +- ⚠️ **Améliorations recommandées** : 20%
15 +- ❌ **Non conforme** : 10%
16 16
17 17 ### Findings par sévérité
18 18
19 -| Sévérité | Nombre |
20 -|----------|--------|
21 -| Critical | 1 |
22 -| High | 3 |
23 -| Medium | 5 |
24 -| Low | 3 |
25 -| Info | 2 |
19 +| Sévérité | Nombre | Dont nouveaux (FUI-*) |
20 +|----------|--------|-----------------------|
21 +| 🔴 Critical | 2 | 1 |
22 +| 🟠 High | 4 | 1 |
23 +| 🟡 Medium | 7 | 2 |
24 +| 🟢 Low | 5 | 2 |
25 +| ℹ️ Info | 3 | 1 |
26 +
27 +### Évolution vs rapport précédent
28 +
29 +Le score baisse de 80% → 70% en raison de :
30 +1. **Une nouvelle CVE Critical** (RUSTSEC-2026-0141, lettre 0.11.19, publiée le 2026-05-14).
31 +2. **L'élargissement du périmètre** à `rustwarden-fediverse-ui` (+3 329 LoC), qui introduit 5 nouveaux findings.
32 +3. **Aucune remédiation appliquée** depuis le 2026-05-13 : les 9 findings d'origine sur `rustwarden-fediverse` sont tous **encore présents**.
26 33
27 34 ---
28 35
@@ -32,38 +39,62 @@
32 39
33 40 #### ✅ Règle LIBS-OUTDATED — CONFORME
34 41
35 -- `Cargo.toml` déclare toutes les dépendances avec des versions fixées ou workspace-inherited.
36 -- **Note** : `activitypub_federation = "=0.7.0-beta.11"` est une beta pinned exacte — à surveiller pour les mises à jour de sécurité.
42 +- `rustwarden-fediverse/Cargo.toml` : dépendances fixées ou workspace-inherited. `activitypub_federation = "=0.7.0-beta.11"` (beta pinnée exacte, à surveiller).
43 +- `rustwarden-fediverse-ui/Cargo.toml` : 16 dépendances déclarées, toutes workspace-inherited ou exactes. Pas de dep inattendue.
44 +
45 +#### ❌ Règle LIBS-AUDIT — NON CONFORME — Sévérité Critical
46 +
47 +#### Finding FUI-DEP-1 — CVE RUSTSEC-2026-0141 sur `lettre 0.11.19`
48 +
49 +**Localisation** : `rustwarden-core` → propagée à `rustwarden-fediverse-ui` et `rustwarden-fediverse` via l'arbre de dépendances.
50 +
51 +`cargo audit` signale :
52 +
53 +```
54 +Crate: lettre
55 +Version: 0.11.19
56 +Title: TLS hostname verification disabled when using Boring TLS backend
57 +Date: 2026-05-14
58 +ID: RUSTSEC-2026-0141
59 +URL: https://rustsec.org/advisories/RUSTSEC-2026-0141
60 +Severity: 9.1 (critical)
61 +Solution: Upgrade to >=0.11.22
62 +```
37 63
38 -#### ✅ Règle LIBS-AUDIT — CONFORME
64 +**Exploitabilité** : Si le backend TLS utilise Boring TLS, la vérification du nom d'hôte TLS est désactivée. Un attaquant en position MITM peut intercepter les emails SMTP en clair.
39 65
40 -- `cargo audit` (606 deps scannées, exit 0) : **0 vulnérabilité connue**.
41 -- Dépendance `subtle = "2.6"` présente dans `Cargo.toml` — bon signe pour les comparaisons constant-time, mais **non utilisée explicitement dans le code source** (cf. section 6).
66 +**Blast radius** : Interception du contenu email (tokens de réinitialisation, notifications, invitations fediverse).
67 +
68 +**Remédiation** :
69 +```toml
70 +# workspace Cargo.toml
71 +[workspace.dependencies]
72 +lettre = "0.11.22"
73 +```
74 +
75 +Puis `cargo update -p lettre`.
76 +
77 +**Remarque** : la CVE a été publiée le 2026-05-14 (jour de cet audit). Le rapport précédent (2026-05-13) affichait 0 CVE car l'advisory n'existait pas encore.
42 78
43 79 ### 1.2 Compilation et Build
44 80
45 81 #### ❌ Règle BUILD-WARNINGS — NON CONFORME — Sévérité Low
46 82
47 -**Statut** : `#![deny(warnings)]` absent.
83 +**Statut** : `#![deny(warnings)]` absent sur les deux crates.
48 84
49 85 **Fichiers concernés** :
50 86 - `crates/rustwarden-fediverse/src/lib.rs:1`
87 +- `crates/rustwarden-fediverse-ui/src/lib.rs:1`
51 88 - `crates/rustwarden-fediverse/migration/src/lib.rs:1`
52 89
53 -Le workspace déclare `[workspace.lints.rust] missing_docs = "warn"` mais aucun `deny(warnings)` au niveau crate. Clippy pedantic produit 10 warnings non fatals.
90 +Le workspace déclare `[workspace.lints.rust] missing_docs = "warn"` mais aucun `deny(warnings)` au niveau crate.
54 91
55 92 **Recommandation** :
56 93 ```rust
57 -// crates/rustwarden-fediverse/src/lib.rs, ligne 1
94 +// crates/rustwarden-fediverse-ui/src/lib.rs, ligne 1
58 95 #![deny(warnings)]
59 96 ```
60 97
61 -Ou, préférablement, au niveau workspace dans `Cargo.toml` :
62 -```toml
63 -[workspace.lints.rust]
64 -warnings = "deny"
65 -```
66 -
67 98 ---
68 99
69 100 ## 2. Gestion de la Mémoire
@@ -71,7 +102,7 @@ warnings = "deny"
71 102 ### 2.1 Mémoire Non Initialisée
72 103
73 104 #### ✅ Règle MEM-UNINIT — CONFORME
74 -Aucune utilisation de `std::mem::uninitialized` détectée dans la crate.
105 +Aucune utilisation de `std::mem::uninitialized` détectée dans les deux crates.
75 106
76 107 #### ✅ Règle MEM-MAYBEUNINIT — CONFORME
77 108 Aucune utilisation de `MaybeUninit` détectée.
@@ -84,7 +115,7 @@ Aucune utilisation de `std::mem::forget` détectée.
84 115 ### 2.3 Pointeurs Bruts
85 116
86 117 #### ✅ Règle MEM-RAW-PTR — CONFORME
87 -Aucun pointeur brut (`*const T`, `*mut T`) détecté dans le code source ou les migrations.
118 +Aucun pointeur brut (`*const T`, `*mut T`) détecté dans le code source.
88 119
89 120 ---
90 121
@@ -94,41 +125,22 @@ Aucun pointeur brut (`*const T`, `*mut T`) détecté dans le code source ou les
94 125
95 126 #### ✅ Règle UNSAFE-JUSTIFY — CONFORME (avec petits manques)
96 127
97 -**Statut** : Toutes les utilisations de `unsafe` sont dans du code de test, et la plupart sont documentées avec `// SAFETY:`.
98 -
99 -**Utilisations détectées** (toutes dans `#[cfg(test)]`) :
100 -
101 -| Fichier | Lignes | Justification SAFETY |
102 -|---------|--------|---------------------|
103 -| `src/config.rs` | 339, 349 | ✅ Oui (`// SAFETY: tests in this module are serialised through ENV_LOCK`) |
104 -| `src/services/actor_key_service.rs` | 175, 185 | ✅ Oui (`// SAFETY: tests run sequentially`) |
105 -| `tests/e2e_fediverse.rs` | 321, 323, 477, 479 | ❌ **Non** — `// SAFETY:` absent |
128 +**`rustwarden-fediverse`** : 8 occurrences dans les tests, 0 en production. 4 blocs manquent de `// SAFETY:` dans `tests/e2e_fediverse.rs` (lignes 321, 323, 477, 479).
106 129
107 -**Sévérité** : Low
108 -
109 -**Recommandation** :
110 -```rust
111 -// SAFETY: e2e tests run sequentially (--test-threads=1 in CI for this crate).
112 -// The env var is restored to its previous value after use.
113 -unsafe { std::env::set_var("ENCRYPTION_KEY", BETA_ENCRYPTION_KEY) };
114 -```
130 +**`rustwarden-fediverse-ui`** : 0 occurrence dans le code source. **Strictement conforme**.
115 131
116 132 #### ✅ Règle UNSAFE-MINIMIZE — CONFORME
117 -
118 -- 0 occurrences dans le code de production.
119 -- 8 occurrences dans les tests, toutes pour `std::env::set_var` / `remove_var` (unsafe depuis Rust 1.66).
133 +0 `unsafe` en code de production dans les deux crates.
120 134
121 135 #### ✅ Recommandation UNSAFE-REVIEW — CONFORME
122 136
123 -Aucun `unsafe` en production. Les usages test ont un périmètre limité et sont sérialisés via `Mutex<()>`.
124 -
125 137 ---
126 138
127 139 ## 4. Foreign Function Interface (FFI)
128 140
129 141 #### ✅ Règle FFI-TYPES — CONFORME (N/A)
130 142
131 -Aucune interface FFI détectée. Pas de `extern`, `#[link]`, `CStr`, `CString`, `c_char` dans la crate.
143 +Aucune interface FFI détectée dans les deux crates. Pas de `extern`, `#[link]`, `CStr`, `CString`, `c_char`.
132 144
133 145 ---
134 146
@@ -136,36 +148,20 @@ Aucune interface FFI détectée. Pas de `extern`, `#[link]`, `CStr`, `CString`,
136 148
137 149 ### 5.1 Types de Résultat
138 150
139 -#### ⚠️ Règle ERR-RESULT — AMÉLIORATION RECOMMANDÉE — Sévérité Medium
151 +#### ⚠️ Règle ERR-RESULT — AMÉLIORATION RECOMMANDÉE
140 152
141 -**Statut** : 2 occurrences de `expect()` dans le code de production.
153 +**`rustwarden-fediverse`** :
142 154
143 155 | # | Fichier:Ligne | Code | Contexte |
144 156 |---|--------------|------|----------|
145 -| 1 | `src/config.rs:30` | `.expect("valid config")` | Doctest uniquement (dans ```` ``` ignore ````). Acceptable. |
146 -| 2 | `src/services/fediverse_signal_service.rs:272` | `.expect("reqwest client builder must not fail")` | Code de production dans `AlwaysAcceptVerifier::with_resolver()`. |
157 +| F-ERR-1 | `src/services/fediverse_signal_service.rs:272` | `.expect("reqwest client builder must not fail")` | Code de production dans `AlwaysAcceptVerifier::with_resolver()`. |
158 +| — | `src/services/fediverse_signal_service.rs:727, 737, 849, 881` | `.expect(...)` | Test helpers (acceptables). |
147 159
148 -**Finding F-ERR-1** : `src/services/fediverse_signal_service.rs:272`
160 +**Finding F-ERR-1** (inchangé depuis 2026-05-13) — Sévérité **Medium**
149 161
150 -Le `reqwest::Client::builder().timeout(...).build().expect(...)` peut paniquer si la configuration TLS native échoue (certificats système manquants, runtime mal configuré). C'est dans le constructeur d'`AlwaysAcceptVerifier::with_resolver()` qui est documenté comme test/dev-only, mais le code est compilé en mode production.
162 +Le `reqwest::Client::builder().build().expect(...)` peut paniquer si la configuration TLS native échoue. Le verifier est documenté dev-only mais compile en mode production.
151 163
152 -**Sévérité** : Medium (le verifier est dev-only par contrat, mais le panic est en code prod)
153 -
154 -**Recommandation** :
155 -```rust
156 -// BAD
157 -let http = reqwest::Client::builder()
158 - .timeout(ACTOR_FETCH_TIMEOUT)
159 - .build()
160 - .expect("reqwest client builder must not fail");
161 -
162 -// GOOD
163 -let http = reqwest::Client::builder()
164 - .timeout(ACTOR_FETCH_TIMEOUT)
165 - .build()
166 - .map_err(|e| AppError::Internal(format!("reqwest client build failed: {e}")))?;
167 -// (requires changing with_resolver return type to Result)
168 -```
164 +**`rustwarden-fediverse-ui`** : aucun `expect()` ni `unwrap()` dans le code de production. **Strictement conforme**.
169 165
170 166 ---
171 167
@@ -175,299 +171,187 @@ let http = reqwest::Client::builder()
175 171
176 172 #### ✅ Règle CRYPTO-RNG — CONFORME
177 173
178 -**Fichier** : `src/services/actor_key_service.rs:47-48`
179 -
180 -```rust
181 -let mut rng = OsRng;
182 -let private_key = RsaPrivateKey::new(&mut rng, RSA_BITS)?;
183 -```
184 -
185 -- Source d'aléa : `OsRng` (CSPRNG du système). **Conforme**.
186 -- Taille de clé : `RSA_BITS = 2048` (ligne 22). Conforme au minimum ANSSI (RSA-2048+).
187 -- Encodage : PKCS#8 (private) / SPKI (public). Standard.
188 -- Stockage : la clé privée est chiffrée via `CryptoService::encrypt()` (AES-256-GCM, cf. `rustwarden-core`) avant persistance. Le code refuse de générer si `ENCRYPTION_KEY` n'est pas configurée (ligne 113-116). **Conforme**.
174 +Inchangé. `actor_key_service.rs:47-48` — `OsRng` + RSA-2048.
189 175
190 176 ### 6.2 Nonce Cryptographique (RFC 0006)
191 177
192 178 #### ✅ CONFORME
193 179
194 -**Fichier** : `src/services/fediverse_auth_service.rs:570-573`
195 -
196 -```rust
197 -fn generate_nonce() -> String {
198 - let mut buf = [0u8; 32];
199 - rand::rng().fill_bytes(&mut buf);
200 - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
201 -}
202 -```
203 -
204 -- 256 bits d'entropie via `rand::rng()` (ThreadRng, `OsRng`-backed depuis rand 0.9). **Conforme**.
205 -- Encodage URL-safe base64 sans padding. **Conforme**.
206 -- Test de non-répétition présent (`generate_nonce_is_unique_and_url_safe`).
180 +Inchangé. `fediverse_auth_service.rs:570-573` — 256 bits via `rand::rng()`.
207 181
208 182 ### 6.3 Signatures HTTP (RFC 0015/0016)
209 183
210 -#### ⚠️ CONFORME avec RÉSERVES — Sévérité High
211 -
212 -**Fichier** : `src/http_signatures.rs`
213 -
214 -**Points positifs** :
215 -- Algorithme : `rsa-sha256` uniquement accepté (PKCS#1 v1.5 + SHA-256). Les algorithmes non supportés sont rejetés (ligne 162-166).
216 -- Vérification via `rsa::pkcs1v15::VerifyingKey` — implémentation auditée de la crate `rsa`.
217 -- Tests de non-régression : signature valide acceptée, signature altérée rejetée, mauvaise clé publique rejetée.
218 -
219 184 #### Finding F-CRYPTO-1 — Absence de vérification du header `Digest` — Sévérité **High**
220 185
221 -**Fichier** : `src/http_signatures.rs:169`
186 +**Fichier** : `crates/rustwarden-fediverse/src/http_signatures.rs:169`
222 187
223 -```rust
224 -let _ = body; // body is hashed via the `digest` header by Mastodon when present.
225 -```
226 -
227 -Le paramètre `body` de `parse_and_verify_cavage()` est **ignoré**. La vérification de signature porte sur les headers signés (`(request-target)`, `host`, `date`) mais **pas sur le body digest**. Un attaquant qui intercepte une requête signée peut :
228 -1. Conserver les headers signés intacts.
229 -2. Remplacer le body JSON par un payload malveillant.
230 -3. Le serveur vérifie la signature (qui porte sur les headers) et accepte le body falsifié.
231 -
232 -**Exploitabilité** : Requiert un MITM ou un accès aux logs du load balancer (les headers signés y transitent). Modérée.
233 -
234 -**Blast radius** : Injection d'activités AP arbitraires (Update/Delete/Revoke) sous l'identité d'un acteur légitime.
235 -
236 -**Remédiation** :
237 -```rust
238 -// GOOD: verify body digest before signature check
239 -pub fn parse_and_verify_cavage(
240 - signature_header: &str,
241 - request_target: &str,
242 - body: &[u8],
243 - public_pem: &str,
244 - headers: impl Fn(&str) -> Option<String>,
245 -) -> Result<(), AppError> {
246 - let parsed = parse_signature_header(signature_header)?;
247 - // ...existing algorithm check...
248 -
249 - // Verify body digest if present in signed headers
250 - if parsed.headers.contains(&"digest".to_string()) {
251 - let expected_digest = headers("digest").ok_or_else(|| {
252 - AppError::Validation("Signature includes digest but header missing".into())
253 - })?;
254 - let computed = format!(
255 - "SHA-256={}",
256 - base64::engine::general_purpose::STANDARD.encode(
257 - sha2::Sha256::digest(body)
258 - )
259 - );
260 - if expected_digest != computed {
261 - return Err(AppError::Validation("Digest mismatch".into()));
262 - }
263 - }
264 - // ...rest of verification...
265 -}
266 -```
188 +**Statut** : INCHANGÉ depuis 2026-05-13. Le body est toujours ignoré (`let _ = body;`). Un attaquant MITM peut remplacer le body JSON tout en conservant les headers signés.
267 189
268 190 #### Finding F-CRYPTO-2 — Absence de validation temporelle du header `Date` — Sévérité **High**
269 191
270 -**Fichier** : `src/http_signatures.rs` (ensemble du module)
271 -
272 -Le header `Date` est inclus dans la chaîne signée (ligne 296-299 pour la signature sortante), mais le vérificateur entrant ne contrôle **jamais** l'écart entre `Date` et l'heure courante. Un attaquant qui capture une requête signée peut la **rejouer indéfiniment**.
192 +**Fichier** : `crates/rustwarden-fediverse/src/http_signatures.rs` (module entier)
273 193
274 -La pratique standard (Mastodon, Pleroma, Misskey) est de rejeter les signatures dont le `Date` header s'écarte de plus de 30 secondes à 5 minutes de l'heure serveur.
275 -
276 -**Exploitabilité** : Requiert la capture d'une requête signée valide. Modérée à élevée si les logs sont accessibles.
277 -
278 -**Blast radius** : Replay d'activités Delete/Update/Revoke, potentiellement destructeur.
279 -
280 -**Remédiation** :
281 -```rust
282 -// Inside HttpSigVerifier::verify() or parse_and_verify_cavage():
283 -if let Some(date_str) = ctx.headers.get("date").and_then(|v| v.to_str().ok()) {
284 - let parsed = chrono::DateTime::parse_from_rfc2822(date_str)
285 - .map_err(|_| AppError::Validation("invalid Date header".into()))?;
286 - let skew = (Utc::now() - parsed.with_timezone(&Utc)).num_seconds().abs();
287 - if skew > 300 {
288 - return Err(AppError::Validation(format!(
289 - "Date header too far from server time ({skew}s > 300s)"
290 - )));
291 - }
292 -}
293 -```
194 +**Statut** : INCHANGÉ. Aucun contrôle d'écart entre `Date` et l'heure serveur. Replay indéfini possible.
294 195
295 196 ### 6.4 Comparaisons Constant-Time
296 197
297 198 #### ⚠️ AMÉLIORATION RECOMMANDÉE — Sévérité Low
298 199
299 -La crate `subtle = "2.6"` est déclarée dans `Cargo.toml` mais **aucun import ou usage** de `subtle::ConstantTimeEq` n'est présent dans le code source. Les comparaisons de signatures sont effectuées par la crate `rsa` (qui utilise `subtle` en interne), donc ce point est couvert **indirectement**. Néanmoins, si des comparaisons de nonces ou tokens sont ajoutées à l'avenir, `subtle` devrait être utilisé explicitement.
200 +`subtle = "2.6"` déclarée mais non utilisée explicitement. Couverte indirectement par la crate `rsa`. Inchangé.
300 201
301 202 ---
302 203
303 204 ## 7. Surface d'Attaque Réseau
304 205
305 -### 7.1 Inbox Handler
306 -
307 -#### ⚠️ CONFORME avec RÉSERVES
206 +### 7.1 Inbox Handler (rustwarden-fediverse)
308 207
309 -**Fichier** : `src/inbox.rs`
208 +#### Finding F-NET-1 — Absence de limite de taille du body sur `POST /inbox` — Sévérité **Medium**
310 209
311 -**Points positifs** :
312 -- Vérification de signature obligatoire (ligne 55-60 : rejet si `Signature` header absent).
313 -- Trust policy gate RFC 0027 appliquée AVANT la vérification de signature (ligne 71-80). **Conforme**.
314 -- Dispatch type-safe par `activity.kind` avec rejet explicite des types inconnus (ligne 100-102).
315 -- Validation JSON structurée via `serde_json::from_slice` (ligne 62-63).
210 +**Statut** : INCHANGÉ. Aucun `DefaultBodyLimit` appliqué sur les routes `/inbox` et `/fediverse/auth/inbox`.
316 211
317 -#### Finding F-NET-1 — Absence de limite de taille du body sur `POST /inbox` — Sévérité **Medium**
212 +### 7.2 SSRF Guard
318 213
319 -**Fichiers** : `src/inbox.rs`, `src/routes.rs`, `src/auth_routes.rs`
214 +#### ✅ CONFORME
320 215
321 -Aucun `RequestBodyLimit` (tower-http) ni `axum::extract::DefaultBodyLimit` n'est appliqué sur les routes `POST /inbox` et `POST /fediverse/auth/inbox`. Un attaquant peut envoyer un body JSON de taille arbitraire, provoquant une allocation mémoire proportionnelle.
216 +`ssrf_guard()` dans `fediverse_signal_service.rs:328-367` bloque les IPs privées/loopback/link-local. Inchangé.
322 217
323 -Le fetch d'acteur distant a une limite (`MAX_ACTOR_BODY_BYTES = 1 MiB`, ligne 313), mais l'inbox n'en a pas.
218 +### 7.3 WebFinger
324 219
325 -**Exploitabilité** : Remote, unauthenticated (l'inbox est public par spec AP).
220 +#### ✅ CONFORME
326 221
327 -**Blast radius** : Denial of Service par épuisement mémoire.
222 +Validation du préfixe `acct:`, du domaine, des segments vides. Inchangé.
328 223
329 -**Remédiation** :
330 -```rust
331 -// In src/routes.rs, router_with():
332 -use axum::extract::DefaultBodyLimit;
224 +### 7.4 Auth Flow — `redirect_url` Non Sanitisé
333 225
334 -Router::new()
335 - .route("/inbox", post(inbox::handle))
336 - .layer(DefaultBodyLimit::max(256 * 1024)) // 256 KiB
337 - // ...
338 -```
226 +#### Finding F-NET-2 — `redirect_url` non validé — Sévérité **Medium**
339 227
340 -### 7.2 Outbound Fetch (SSRF)
228 +**Fichier** : `crates/rustwarden-fediverse/src/auth_routes.rs:55`
341 229
342 -#### ✅ CONFORME
230 +**Statut** : INCHANGÉ. Le champ `redirect_url: Option<String>` est passé en base sans validation de scheme ni de domaine.
343 231
344 -**Fichier** : `src/services/fediverse_signal_service.rs:328-367`
232 +**Note UI** : `rustwarden-fediverse-ui` passe `redirect_url: None` dans `login_initiate` (`user_auth.rs:302`), donc le vecteur d'attaque ne passe pas par l'UI mais reste exploitable via l'API JSON directement.
345 233
346 -La fonction `ssrf_guard()` :
347 -- Bloque les IPs loopback (`127.0.0.0/8`, `::1`).
348 -- Bloque les IPs privées RFC 1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`).
349 -- Bloque les IPs link-local (`169.254.0.0/16`, `fe80::/10`).
350 -- Bloque les IPs ULA IPv6 (`fc00::/7`).
351 -- Bloque les IPs unspecified (`0.0.0.0`, `::`).
352 -- Le bypass `allow_private_ips=true` n'est utilisé que par `AlwaysAcceptVerifier` (dev/test-only, documenté ligne 267).
353 -- Timeout : 10s (`ACTOR_FETCH_TIMEOUT`).
354 -- Body cap : 1 MiB (`MAX_ACTOR_BODY_BYTES`).
355 -- Test de régression présent (`ssrf_guard_rejects_loopback_when_strict`).
234 +### 7.5 Invite Routes — Autorisation
356 235
357 -### 7.3 WebFinger
236 +#### Finding F-NET-3 — Routes invite sans autorisation obligatoire — Sévérité **High**
358 237
359 -#### ✅ CONFORME
238 +**Fichier** : `crates/rustwarden-fediverse/src/invite_routes.rs:110`
360 239
361 -**Fichier** : `src/webfinger.rs`
240 +**Statut** : INCHANGÉ. Le handler `invite()` accepte `claims: Option<Extension<Claims>>` et fallback sur `system_actor_user_id` quand absent.
362 241
363 -- Validation du préfixe `acct:` obligatoire (ligne 89-91).
364 -- Validation du domaine : rejet si `domain != state.config.domain()` (ligne 35-37).
365 -- Validation des segments vides (ligne 97-101).
366 -- Header `Access-Control-Allow-Origin: *` correct pour WebFinger (RFC 7033 §3.2).
242 +### 7.6 Consent Endpoint — CSRF (API)
367 243
368 -### 7.4 Auth Flow — `redirect_url` Non Sanitisé
244 +#### Finding F-NET-4 — CSRF non appliqué sur `/api/v1/fediverse/auth/consent` (API) — Sévérité **Medium**
369 245
370 -#### Finding F-NET-2 — `redirect_url` non validé — Sévérité **Medium**
246 +**Fichier** : `crates/rustwarden-fediverse/src/auth_routes.rs:99, 252-277`
371 247
372 -**Fichier** : `src/auth_routes.rs:55`, `src/services/fediverse_auth_service.rs:74,142`
248 +**Statut** : INCHANGÉ. Le endpoint API n'a toujours pas de vérification CSRF intrinsèque.
373 249
374 -Le champ `redirect_url: Option<String>` de `InitiateBody` est passé directement à la base de données sans aucune validation. Un attaquant peut injecter une URL arbitraire (`javascript:`, `data:`, ou un domaine tiers) qui sera retournée au client après authentification.
250 +**Attention** : le handler **UI** `consent_submit` dans `rustwarden-fediverse-ui` (`user_auth.rs:591`) applique correctement `csrf::validate_csrf_token`. Le risque est limité aux consommateurs qui appellent l'API JSON directement sans CSRF middleware.
375 251
376 -Ce finding est **identique** à l'observation 463 du `CLAUDE.md` sur le middleware `auth_required` du core.
252 +### 7.7 Placeholder JWT Secret
377 253
378 -**Exploitabilité** : Remote, unauthenticated (l'endpoint `initiate` est public).
254 +#### Finding F-NET-5 — Secret JWT placeholder en code de production — Sévérité **Critical**
379 255
380 -**Blast radius** : Open redirect, phishing.
256 +**Fichier** : `crates/rustwarden-fediverse/src/routes.rs:54, 107-126`
381 257
382 -**Recommandation** :
383 -```rust
384 -// Validate redirect_url before persisting
385 -fn validate_redirect_url(url: &str, allowed_host: &str) -> Result<(), AppError> {
386 - let parsed = url::Url::parse(url)
387 - .map_err(|_| AppError::Validation("invalid redirect URL".into()))?;
388 - if parsed.scheme() != "https" && parsed.scheme() != "http" {
389 - return Err(AppError::Validation("redirect URL must be http(s)".into()));
390 - }
391 - if parsed.host_str() != Some(allowed_host) {
392 - return Err(AppError::Validation("redirect URL host mismatch".into()));
393 - }
394 - Ok(())
395 -}
396 -```
258 +**Statut** : INCHANGÉ. `placeholder_auth_config()` utilise `jwt_secret = "0".repeat(64)` en fallback silencieux. Un déploiement sans variables d'environnement JWT démarre avec un secret prévisible.
397 259
398 -### 7.5 Invite Routes — Autorisation
260 +### 7.8 Handlers Admin sans Claims — Audit Trail Manquant (NOUVEAU)
399 261
400 -#### Finding F-NET-3 — Routes invite sans authorisation obligatoire — Sévérité **High**
262 +#### Finding FUI-AUTH-1 — 4 POST admin destructifs sans extraction de Claims — Sévérité **High**
401 263
402 -**Fichier** : `src/invite_routes.rs:107-124`
264 +**Fichiers** :
265 +- `crates/rustwarden-fediverse-ui/src/handlers/admin.rs:732` — `delete_trust_entry`
266 +- `crates/rustwarden-fediverse-ui/src/handlers/admin.rs:1194` — `revoke_remote_session`
267 +- `crates/rustwarden-fediverse-ui/src/handlers/admin.rs:1224` — `kill_domain`
268 +- `crates/rustwarden-fediverse-ui/src/handlers/admin.rs:1711` — `revoke_team_invite`
403 269
404 -Le handler `invite()` accepte `claims: Option<Extension<Claims>>` (ligne 110). Quand aucun JWT n'est présent, il fallback sur `system_actor_user_id` (ligne 122-123). Cela signifie que les endpoints suivants sont accessibles **sans authentification** :
270 +Ces 4 handlers POST effectuent des **actions destructives** sans extraire `Extension<Claims>` dans leur signature. Ils s'appuient **uniquement** sur le middleware `AdminService` qui vérifie le préfixe `/admin` et le rôle admin.
405 271
406 -- `POST /api/v1/teams/{team_id}/members/invite-fediverse`
407 -- `GET /api/v1/teams/{team_id}/invites`
408 -- `POST /api/v1/teams/{team_id}/invites/{invite_id}/revoke`
409 -- `POST /api/v1/fediverse/invites/{invite_id}/accept`
410 -- `POST /api/v1/fediverse/invites/{invite_id}/reject`
272 +**Problème** :
273 +1. Aucune trace d'audit de QUI a effectué l'action destructive (pas de `claims.sub` loggé ni persisté).
274 +2. Si le middleware est contourné (bug, refactor, changement de préfixe), les handlers exécutent sans auth.
275 +3. Violation du principe de défense en profondeur (ANSSI : vérifier l'autorisation à chaque couche).
411 276
412 -Le commentaire (ligne 118-120) documente que c'est intentionnel pour le slice minimal, mais c'est un **risque de production** : un attaquant non authentifié peut inviter n'importe qui dans n'importe quelle équipe.
277 +**Comparaison** : `update_settings`, `add_trust_entry`, `create_team_invite` extraient correctement `Extension(claims): Extension<Claims>`.
413 278
414 -**Exploitabilité** : Remote, unauthenticated.
279 +**Exploitabilité** : Faible aujourd'hui (le middleware `AdminService` est en place). Moyenne en cas de refactor.
415 280
416 -**Blast radius** : Élévation de privilèges (ajout d'un remote identity comme admin d'une team).
281 +**Blast radius** : Actions destructives non auditables : suppression de domaines de confiance, coupure de toutes les sessions d'un domaine, révocation d'invitations.
417 282
418 -**Remédiation** : Appliquer le middleware `auth_required` sur les routes inviter-side (`/api/v1/teams/...`) et ne garder le fallback que sur les routes IdP-side.
283 +**Remédiation** :
284 +```rust
285 +// BAD: no Claims extraction, no audit trail
286 +pub async fn delete_trust_entry(
287 + State(db): State<DatabaseConnection>,
288 + session: Session,
289 + Form(form): Form<DeleteTrustEntryForm>,
290 +) -> Result<impl IntoResponse, AppError> {
291 +
292 +// GOOD: Claims extracted, audit trail possible
293 +pub async fn delete_trust_entry(
294 + Extension(claims): Extension<Claims>,
295 + State(db): State<DatabaseConnection>,
296 + session: Session,
297 + Form(form): Form<DeleteTrustEntryForm>,
298 +) -> Result<impl IntoResponse, AppError> {
299 + tracing::info!(
300 + admin = %claims.sub,
301 + domain = %form.domain,
302 + "admin deleted trust entry"
303 + );
304 +```
419 305
420 -### 7.6 Consent Endpoint — CSRF
306 +### 7.9 SSE Endpoint — Absence de Rate Limiting (NOUVEAU)
421 307
422 -#### Finding F-NET-4 — CSRF non appliqué sur `/api/v1/fediverse/auth/consent` — Sévérité **Medium**
308 +#### Finding FUI-DOS-1 — SSE endpoint public sans rate limiting — Sévérité **Medium**
423 309
424 -**Fichier** : `src/auth_routes.rs:99,252-277`
310 +**Fichier** : `crates/rustwarden-fediverse-ui/src/handlers/user_auth.rs:402-408`
425 311
426 -Le commentaire ligne 99 mentionne "CSRF-protected by the consumer middleware" mais la route elle-même **ne vérifie aucun token CSRF**. Si le consumer oublie de monter le middleware CSRF, l'endpoint est exploitable par cross-site request forgery.
312 +**Route** : `GET /login/fediverse/sse/{challenge_id}` (publique, sans authentification).
427 313
428 -### 7.7 Placeholder JWT Secret
314 +Le SSE endpoint maintient une connexion HTTP longue jusqu'à 5 minutes (`SSE_MAX_POLLS = 150` × `SSE_POLL_INTERVAL = 2s`). Chaque connexion détient un handle `DatabaseConnection` et une tâche Tokio.
429 315
430 -#### Finding F-NET-5 — Secret JWT placeholder en code de production — Sévérité **Critical**
316 +Un attaquant peut ouvrir des milliers de connexions SSE simultanées, épuisant :
317 +1. Le pool de connexions PostgreSQL.
318 +2. Les tâches Tokio (mémoire + CPU polling).
319 +3. Les file descriptors du processus.
431 320
432 -**Fichier** : `src/routes.rs:107-126`
321 +**Exploitabilité** : Remote, unauthenticated.
433 322
434 -```rust
435 -fn placeholder_auth_config() -> AuthConfig {
436 - AuthConfig {
437 - jwt_secret: "0".repeat(64),
438 - // ...
439 - }
440 -}
441 -```
323 +**Blast radius** : Denial of Service.
442 324
443 -La fonction `placeholder_auth_config()` est appelée ligne 54 quand `AuthConfig::from_env()` échoue :
325 +**Atténuation existante** : les challenge IDs invalides ferment après un seul poll. Les IDs valides en revanche maintiennent le poll pendant 5 minutes.
444 326
327 +**Remédiation** :
445 328 ```rust
446 -let auth_flow_state = AuthFlowState {
447 - auth_config: Arc::new(AuthConfig::from_env().unwrap_or_else(|_| placeholder_auth_config())),
448 - // ...
449 -};
329 +use tower::limit::ConcurrencyLimitLayer;
330 +
331 +Router::new()
332 + .route(
333 + "/login/fediverse/sse/{challenge_id}",
334 + get(user_auth::sse_status),
335 + )
336 + .layer(ConcurrencyLimitLayer::new(100)) // max 100 concurrent SSE
450 337 ```
451 338
452 -Si les variables d'environnement JWT ne sont pas configurées (déploiement mal configuré, container sans `.env`), le serveur démarre avec un `jwt_secret = "000...0"` (64 zéros). Tout JWT signé avec ce secret est prévisible : un attaquant peut forger des tokens valides.
339 +### 7.10 Absence de Body Size Limit sur les routes POST UI (NOUVEAU)
453 340
454 -**Exploitabilité** : Remote, unauthenticated si le déploiement est mal configuré.
341 +#### Finding FUI-NET-1 — Pas de `DefaultBodyLimit` sur les POST publiques — Sévérité **Low**
455 342
456 -**Blast radius** : Forgery de JWT arbitraires, usurpation d'identité complète.
343 +**Fichier** : `crates/rustwarden-fediverse-ui/src/routes.rs`
344 +
345 +Axum applique un défaut de 2 MiB, raisonnable pour des formulaires HTML, mais les endpoints publics (`POST /login/fediverse`) devraient avoir une limite plus stricte (les formulaires ne dépassent jamais 4 KiB).
457 346
458 347 **Remédiation** :
459 348 ```rust
460 -// BAD: silent fallback to weak secret
461 -AuthConfig::from_env().unwrap_or_else(|_| placeholder_auth_config())
462 -
463 -// GOOD: fail loudly
464 -AuthConfig::from_env().map_err(|e| {
465 - tracing::error!("AuthConfig::from_env() failed: {e} — refusing to start with placeholder");
466 - panic!("JWT configuration is required for federation auth routes");
467 -})?
468 -```
349 +use axum::extract::DefaultBodyLimit;
469 350
470 -Ou, si le router doit rester constructible sans env vars (pour les tests), utiliser `router_with_auth()` exclusivement en production.
351 +Router::new()
352 + .route("/login/fediverse", get(...).post(...))
353 + .layer(DefaultBodyLimit::max(8 * 1024)) // 8 KiB
354 +```
471 355
472 356 ---
473 357
@@ -477,29 +361,28 @@ Ou, si le router doit rester constructible sans env vars (pour les tests), utili
477 361
478 362 #### ✅ CONFORME
479 363
480 -Tout le code de production utilise exclusivement SeaORM (`Entity::find()`, `ActiveModel::insert/update`, `Entity::delete_many()` avec `QueryFilter`). Aucun `format!()` avec du SQL. Aucune concaténation de chaîne dans des requêtes.
481 -
482 -### 8.2 Migrations
483 -
484 -#### ✅ CONFORME avec RÉSERVE — Sévérité Info
485 -
486 -**Fichier** : `migration/src/m20260512_000001_fediverse_protocol_schema.rs`
364 +**`rustwarden-fediverse`** : tout SeaORM, pas de raw SQL en production. Inchangé.
487 365
488 -- Les seeds `app_settings` utilisent `Query::insert()` avec des valeurs bound (lignes 112-133). **Conforme**.
489 -- Les deletes utilisent `Query::delete()` avec `Expr::col(...).eq(key)` (lignes 440-444). **Conforme**.
490 -- **Reste** 2 appels `execute_unprepared()` :
491 - - Ligne 362 : `"CREATE INDEX fediverse_pending_invites_status_idx ON fediverse_pending_invites (status, expires_at)"` — DDL statique, pas d'injection possible.
492 - - Ligne 373 : `"DROP INDEX IF EXISTS fediverse_pending_invites_status_idx"` — DDL statique.
366 +**`rustwarden-fediverse-ui`** : tout SeaORM exclusivement. Vérification exhaustive des handlers :
493 367
494 -Ces usages sont acceptables car ce sont des DDL statiques sans interpolation de données utilisateur.
368 +| Handler | Backend |
369 +|---------|---------|
370 +| `add_trust_entry` | `FediverseTrustService` + `Entity::update_many` SeaORM |
371 +| `delete_trust_entry` | `FediverseTrustService` SeaORM |
372 +| `revoke_remote_session` | `Entity::update_many().filter(Column::Id.eq(id))` |
373 +| `kill_domain` | `Entity::update_many().filter(Column::Domain.eq(domain))` |
374 +| `create_team_invite` | `FediverseInviteService::invite_remote()` |
375 +| `revoke_team_invite` | `FediverseInviteService::revoke()` |
376 +| `consent_submit` | `FediverseAuthService::record_decision()` |
377 +| `invite_landing_submit` | `FediverseInviteService::accept()/reject()` |
495 378
496 -### 8.3 Tests E2E
379 +Aucun `format!()` avec du SQL. Aucune concaténation de chaîne dans des requêtes.
497 380
498 -#### ⚠️ AMÉLIORATION RECOMMANDÉE — Sévérité Info
381 +### 8.2 Migrations
499 382
500 -**Fichier** : `tests/e2e_fediverse.rs` (lignes 445, 574, 606, 687, 695, 749, 790, 817, 844, 850, 881, 945, 1031)
383 +#### ✅ CONFORME avec RÉSERVE — Sévérité Info
501 384
502 -Les tests e2e utilisent `format!("SELECT ... WHERE id='{challenge_id}'")` avec des `Uuid` interpolés. Ceci n'est pas un risque de sécurité (les valeurs sont des UUID générés par le test, pas des entrées utilisateur), mais c'est contraire à la règle projet "pas de raw SQL". En test e2e, c'est tolérable.
385 +Inchangé. Les 2 appels `execute_unprepared()` dans la migration sont du DDL statique (CREATE/DROP INDEX), sans interpolation.
503 386
504 387 ---
505 388
@@ -509,31 +392,38 @@ Les tests e2e utilisent `format!("SELECT ... WHERE id='{challenge_id}'")` avec d
509 392
510 393 #### ✅ CONFORME (code de production)
511 394
512 -Aucun API key, mot de passe, ou token en dur dans le code de production. Les configurations crypto viennent exclusivement de variables d'environnement :
513 -
514 -- `ENCRYPTION_KEY` : via `CryptoService::is_available()` / `CryptoService::encrypt()`.
515 -- `FEDIVERSE_DOMAIN` : via `FediverseConfig::from_env()`.
516 -- `JWT_SECRET` : via `AuthConfig::from_env()` (mais cf. F-NET-5 sur le fallback).
395 +**`rustwarden-fediverse`** : aucun secret en dur hors blocs `#[cfg(test)]`.
517 396
518 -Les `jwt_secret` en dur dans `src/auth_routes.rs:340` et `src/services/fediverse_auth_service.rs:599` sont dans des blocs `#[cfg(test)]` uniquement.
397 +**`rustwarden-fediverse-ui`** : aucun API key, mot de passe, token en dur. Les CSRF tokens sont générés via `csrf::get_or_create_csrf_token(&session)`. Pas de secret dans les templates.
519 398
520 399 ### 9.2 Protection des Clés Privées
521 400
522 401 #### ✅ CONFORME
523 402
524 -**Fichier** : `src/services/actor_key_service.rs:94-127`
403 +Aucune clé privée (`ap_private_key_pem`) n'est exposée dans les DTO de `rustwarden-fediverse-ui`. Le `AdminTabDTO` ne contient que `label`, `href`, `slug`. Les templates admin ne renderent que des metadonnées publiques.
525 404
526 -- La clé privée RSA n'est **jamais** écrite en clair en base de données.
527 -- `ensure_keys()` refuse de générer si `CryptoService::is_available()` retourne `false` (ligne 113-116).
528 -- Le `ActorKeyMaterial` retourné contient la clé déchiffrée en mémoire ; elle n'est jamais logguée (`tracing::warn` dans le resolver ne logue que `error`, pas la clé).
405 +### 9.3 CSP et Scripts Inline
529 406
530 -### 9.3 Placeholder Domain Rejects
407 +#### Finding FUI-CSP-1 — Script inline requiert `unsafe-inline` — Sévérité **Low**
531 408
532 -#### ✅ CONFORME
409 +**Fichier** : `crates/rustwarden-fediverse-ui/templates/login_waiting.html:20-76`
410 +
411 +Le template contient un bloc `<script>` inline (57 lignes) qui initialise l'`EventSource` SSE. Ce script nécessite `unsafe-inline` dans la directive CSP `script-src` (déjà en place au niveau core, obs. 459 CLAUDE.md).
533 412
534 -**Fichier** : `src/config.rs:231-248`
413 +Les templates admin utilisent `x-init` Alpine.js (4 occurrences) mais avec du code statique (`$watch('saved', ...)`) — risque XSS négligeable.
535 414
536 -Les domaines placeholder (`example.com`, `localhost.localdomain`, `your.tld`, etc.) sont refusés. Les URLs `http://` sont refusées sauf en mode `debug_local_federation`.
415 +**Remédiation idéale** :
416 +```html
417 +<!-- BAD -->
418 +<script>
419 + const challengeId = "{{ challenge_id }}";
420 + const sse = new EventSource("/login/fediverse/sse/" + challengeId);
421 +</script>
422 +
423 +<!-- GOOD -->
424 +<script src="/static/js/fediverse-sse.js"
425 + data-challenge-id="{{ challenge_id }}"></script>
426 +```
537 427
538 428 ---
539 429
@@ -541,45 +431,135 @@ Les domaines placeholder (`example.com`, `localhost.localdomain`, `your.tld`, et
541 431
542 432 #### ✅ Règle CONC-SHARED — CONFORME
543 433
544 -- Partage via `Arc<dyn T>` pour les traits (`LocalUserResolver`, `SignatureVerifier`, `RemoteFetcher`).
545 -- `Mutex<()>` utilisé dans les tests pour sérialiser les mutations de variables d'environnement.
546 -- `#[allow(clippy::await_holding_lock)]` documenté dans `actor_key_service.rs:136-137` avec justification (mock DB, non-bloquant).
434 +- `rustwarden-fediverse` : partage via `Arc<dyn T>`, `Mutex<()>` dans les tests. Inchangé.
435 +- `rustwarden-fediverse-ui` : aucun état mutable partagé dans les handlers (tout est request-scoped via `State`, `Extension`, `Session`). Conforme.
436 +
437 +---
438 +
439 +## 11. Tests et Couverture (hors guide ANSSI, ajout projet)
440 +
441 +### `rustwarden-fediverse`
442 +
443 +Couverture significative : tests unitaires + e2e (1 995 lignes dans `tests/e2e_fediverse.rs`, 314 lignes dans `tests/fediverse_interop.rs`).
444 +
445 +### `rustwarden-fediverse-ui`
446 +
447 +#### Finding FUI-TEST-1 — Couverture de test insuffisante — Sévérité **Info**
448 +
449 +**Fichier** : `crates/rustwarden-fediverse-ui/src/handlers/admin.rs` (2 018 lignes, **0 test**)
450 +
451 +La crate `rustwarden-fediverse-ui` ne contient que **6 tests unitaires**, tous dans `user_auth.rs` (helpers `ErrorReason::parse` et `extract_handle_domain`). Les 11 handlers admin (dont 8 POST destructifs) et les 5 handlers utilisateur n'ont **aucun test d'intégration**.
452 +
453 +**Impact** : régression silencieuse possible lors de refactors, notamment sur les vérifications CSRF et d'autorisation.
454 +
455 +---
456 +
457 +## Matrice CSRF Exhaustive (rustwarden-fediverse-ui)
458 +
459 +| Handler | Route | Méthode | `csrf::validate_csrf_token` | Statut |
460 +|---------|-------|---------|-----------------------------|--------|
461 +| `update_settings` | `/admin/fediverse/settings` | POST | Oui (ligne 350) | ✅ CONFORME |
462 +| `add_trust_entry` | `/admin/fediverse/trust_list/add` | POST | Oui (ligne 670) | ✅ CONFORME |
463 +| `delete_trust_entry` | `/admin/fediverse/trust_list/delete` | POST | Oui (ligne 737) | ✅ CONFORME |
464 +| `revoke_remote_session` | `/admin/fediverse/sessions_remote/revoke` | POST | Oui (ligne 1199) | ✅ CONFORME |
465 +| `kill_domain` | `/admin/fediverse/sessions_remote/kill_domain` | POST | Oui (ligne 1229) | ✅ CONFORME |
466 +| `create_team_invite` | `/admin/fediverse/invites/{id}/add` | POST | Oui (ligne 1684) | ✅ CONFORME |
467 +| `revoke_team_invite` | `/admin/fediverse/invites/{id}/revoke` | POST | Oui (ligne 1717) | ✅ CONFORME |
468 +| `login_initiate` | `/login/fediverse` | POST | Oui (ligne 285) | ✅ CONFORME |
469 +| `consent_submit` | `/fediverse/consent/{id}` | POST | Oui (ligne 591) | ✅ CONFORME |
470 +| `invite_landing_submit` | `/fediverse/invite/{id}` | POST | Oui (ligne 955) | ✅ CONFORME |
471 +
472 +**Résultat** : 10/10 POST handlers appliquent `csrf::validate_csrf_token`. **100% conforme**.
473 +
474 +---
475 +
476 +## Matrice Autorisation Exhaustive (rustwarden-fediverse-ui)
477 +
478 +| Handler | Route | Claims extrait | Middleware | Conforme |
479 +|---------|-------|----------------|------------|----------|
480 +| `get_settings` | `GET /admin/fediverse/settings` | Oui (249) | AdminService | ✅ |
481 +| `update_settings` | `POST /admin/fediverse/settings` | Oui (345) | AdminService | ✅ |
482 +| `get_trust_list` | `GET /admin/fediverse/trust_list` | Oui (538) | AdminService | ✅ |
483 +| `add_trust_entry` | `POST /admin/fediverse/trust_list/add` | Oui (665) | AdminService | ✅ |
484 +| `delete_trust_entry` | `POST /admin/fediverse/trust_list/delete` | **Non** | AdminService | ⚠️ FUI-AUTH-1 |
485 +| `get_queue` | `GET /admin/fediverse/queue` | Oui (858) | AdminService | ✅ |
486 +| `get_sessions_remote` | `GET /admin/fediverse/sessions_remote` | Oui (1083) | AdminService | ✅ |
487 +| `revoke_remote_session` | `POST .../revoke` | **Non** | AdminService | ⚠️ FUI-AUTH-1 |
488 +| `kill_domain` | `POST .../kill_domain` | **Non** | AdminService | ⚠️ FUI-AUTH-1 |
489 +| `get_nodeinfo` | `GET /admin/fediverse/nodeinfo` | Oui (1330) | AdminService | ✅ |
490 +| `get_team_invites` | `GET /admin/fediverse/invites/{id}` | Oui (1568) | AdminService | ✅ |
491 +| `create_team_invite` | `POST .../add` | Oui (1678) | AdminService | ✅ |
492 +| `revoke_team_invite` | `POST .../revoke` | **Non** | AdminService | ⚠️ FUI-AUTH-1 |
493 +| `get_hub` | `GET /admin/fediverse` | Oui (1795) | AdminService | ✅ |
494 +| `get_teams_for_invites` | `GET /admin/fediverse/teams` | Oui (1948) | AdminService | ✅ |
495 +| `error` | `GET /login/fediverse/error` | Non (public) | — | ✅ public |
496 +| `login_form` | `GET /login/fediverse` | Non (public) | — | ✅ public |
497 +| `login_initiate` | `POST /login/fediverse` | Non (public) | — | ✅ public, CSRF ok |
498 +| `waiting` | `GET /login/fediverse/waiting/{id}` | Non (public) | — | ✅ public |
499 +| `sse_status` | `GET /login/fediverse/sse/{id}` | Non (public) | — | ⚠️ FUI-DOS-1 |
500 +| `consent_form` | `GET /fediverse/consent/{id}` | Oui (511) | protected prefix | ✅ |
501 +| `consent_submit` | `POST /fediverse/consent/{id}` | Oui (585) | protected prefix | ✅ |
502 +| `connected_apps` | `GET /profile/connected_apps` | Oui (737) | /profile prefix | ✅ |
503 +| `invite_landing_form` | `GET /fediverse/invite/{id}` | Oui (867) | protected prefix | ✅ |
504 +| `invite_landing_submit` | `POST /fediverse/invite/{id}` | Oui (949) | protected prefix | ✅ |
505 +
506 +---
507 +
508 +## Vérification Templates — XSS (rustwarden-fediverse-ui)
509 +
510 +| Check | Résultat |
511 +|-------|----------|
512 +| Utilisation de `\|safe` (bypass échappement) | **0 occurrence** ✅ |
513 +| Utilisation de `{% autoescape false %}` | **0 occurrence** ✅ |
514 +| Utilisation de `x-html` (Alpine.js) | **0 occurrence** ✅ |
515 +| Utilisation de `innerHTML` / `document.write` | **0 occurrence** ✅ |
516 +| `x-init` avec contenu dynamique | **0** (4 occurrences statiques `$watch`) ✅ |
517 +| Askama échappement par défaut | **Actif** ✅ |
518 +| URL dynamiques dans `href`/`action` | UUIDs server-side (Uuid type-safe) ✅ |
519 +
520 +**Résultat** : aucun vecteur XSS identifié. Askama échappe par défaut tous les `{{ }}`. **Conforme**.
547 521
548 522 ---
549 523
550 524 ## Plan d'Action Prioritaire
551 525
552 -### Priorité 1 — CRITIQUE (corriger avant merge)
526 +### Priorité 1 — CRITIQUE (corriger immédiatement)
553 527
554 528 | # | Finding | Fichier | Action |
555 529 |---|---------|---------|--------|
556 -| F-NET-5 | JWT secret placeholder `"000...0"` en fallback silencieux | `src/routes.rs:54,107-126` | Supprimer le fallback ou le remplacer par un panic explicite |
530 +| **FUI-DEP-1** | CVE RUSTSEC-2026-0141 sur `lettre 0.11.19` (CVSS 9.1) | workspace `Cargo.toml` | Upgrader vers `lettre >= 0.11.22` |
531 +| **F-NET-5** | JWT secret placeholder `"000...0"` en fallback silencieux | `rustwarden-fediverse/src/routes.rs:54,107-126` | Supprimer le fallback ou panic explicite |
557 532
558 533 ### Priorité 2 — HAUTE (corriger dans la semaine)
559 534
560 535 | # | Finding | Fichier | Action |
561 536 |---|---------|---------|--------|
562 -| F-CRYPTO-1 | Body digest non vérifié par `parse_and_verify_cavage` | `src/http_signatures.rs:169` | Implémenter la vérification du header `Digest` |
563 -| F-CRYPTO-2 | Header `Date` non validé temporellement (replay) | `src/http_signatures.rs` (module) | Ajouter un contrôle d'écart max 300s |
564 -| F-NET-3 | Routes invite sans auth obligatoire | `src/invite_routes.rs:110,207-228` | Appliquer `auth_required` middleware |
537 +| **F-CRYPTO-1** | Body digest non vérifié | `rustwarden-fediverse/src/http_signatures.rs:169` | Implémenter vérification du `Digest` |
538 +| **F-CRYPTO-2** | Header `Date` non validé | `rustwarden-fediverse/src/http_signatures.rs` | Contrôle d'écart max 300s |
539 +| **F-NET-3** | Routes invite sans auth | `rustwarden-fediverse/src/invite_routes.rs:110` | Appliquer `auth_required` middleware |
540 +| **FUI-AUTH-1** | 4 POST admin sans Claims (pas d'audit trail) | `rustwarden-fediverse-ui/src/handlers/admin.rs:732,1194,1224,1711` | Extraire `Extension<Claims>` + log `claims.sub` |
565 541
566 542 ### Priorité 3 — MOYENNE (corriger dans le mois)
567 543
568 544 | # | Finding | Fichier | Action |
569 545 |---|---------|---------|--------|
570 -| F-NET-1 | Pas de body size limit sur inbox | `src/inbox.rs`, `src/routes.rs` | Ajouter `DefaultBodyLimit::max(256_000)` |
571 -| F-NET-2 | `redirect_url` non sanitisé | `src/auth_routes.rs:55` | Valider scheme + host |
572 -| F-NET-4 | CSRF non enforced sur consent | `src/auth_routes.rs:252-277` | Documenter ou enforcer |
573 -| F-ERR-1 | `expect()` en code prod (verifier dev-only) | `src/services/fediverse_signal_service.rs:272` | Remplacer par `?` |
574 -| — | `ammonia` déclarée mais non importée dans la crate | `Cargo.toml:42` | Vérifier l'intention |
546 +| **F-NET-1** | Pas de body size limit sur inbox | `rustwarden-fediverse/src/routes.rs` | `DefaultBodyLimit::max(1_048_576)` |
547 +| **F-NET-2** | `redirect_url` non sanitisé | `rustwarden-fediverse/src/auth_routes.rs:55` | Valider scheme + host |
548 +| **F-NET-4** | CSRF non enforced sur consent API | `rustwarden-fediverse/src/auth_routes.rs:252-277` | Enforcer middleware |
549 +| **F-ERR-1** | `expect()` en code prod | `rustwarden-fediverse/src/services/fediverse_signal_service.rs:272` | Remplacer par `?` |
550 +| **FUI-DOS-1** | SSE endpoint sans rate limiting | `rustwarden-fediverse-ui/src/handlers/user_auth.rs:402-408` | `ConcurrencyLimitLayer::new(100)` |
575 551
576 552 ### Priorité 4 — BASSE (backlog)
577 553
578 554 | # | Finding | Fichier | Action |
579 555 |---|---------|---------|--------|
580 -| — | `#![deny(warnings)]` absent | `src/lib.rs` | Ajouter |
581 -| — | `// SAFETY:` manquant sur 4 blocs e2e | `tests/e2e_fediverse.rs:321,323,477,479` | Documenter |
582 -| — | `subtle` déclarée mais non utilisée directement | `Cargo.toml:35` | Retirer ou utiliser explicitement |
556 +| **FUI-NET-1** | Pas de body limit explicite sur POST publiques | `rustwarden-fediverse-ui/src/routes.rs` | `DefaultBodyLimit::max(8_192)` |
557 +| **FUI-CSP-1** | Script inline requiert `unsafe-inline` | `templates/login_waiting.html:20-76` | Externaliser en `.js` |
558 +| **FUI-TEST-1** | 0 test sur `admin.rs` (2018 lignes) | `rustwarden-fediverse-ui/src/handlers/admin.rs` | Tests d'intégration CSRF + auth |
559 +| — | `#![deny(warnings)]` absent | `lib.rs` des deux crates | Ajouter |
560 +| — | `// SAFETY:` manquant sur 4 blocs e2e | `rustwarden-fediverse/tests/e2e_fediverse.rs:321,323,477,479` | Documenter |
561 +| — | `subtle` déclarée mais non utilisée | `rustwarden-fediverse/Cargo.toml:35` | Retirer ou utiliser explicitement |
562 +| — | `ammonia` déclarée mais non importée | `rustwarden-fediverse/Cargo.toml:42` | Vérifier l'intention |
583 563
584 564 ---
585 565
@@ -587,34 +567,49 @@ Les domaines placeholder (`example.com`, `localhost.localdomain`, `your.tld`, et
587 567
588 568 ### Avant chaque Commit
589 569
590 -- [x] `cargo clippy` ne signale pas de problèmes (0 erreur, 10 warnings pedantic cosmétiques)
591 -- [x] `cargo audit` ne signale pas de vulnérabilités (0 CVE sur 606 deps)
570 +- [x] `cargo clippy` ne signale pas de problèmes critiques (0 erreur, ~10 warnings pedantic cosmétiques)
571 +- [ ] `cargo audit` ne signale pas de vulnérabilités (**1 CVE active — FUI-DEP-1**)
592 572 - [x] Tous les tests passent
593 573 - [x] Aucun `unsafe` dans le code de production
594 -- [ ] `#![deny(warnings)]` actif (à ajouter)
574 +- [ ] `#![deny(warnings)]` actif (absent sur les 2 crates)
595 575 - [x] Pas de raw SQL dans le code de production
596 576 - [x] Secrets en variables d'environnement uniquement
577 +- [x] CSRF vérifié sur tous les POST handlers UI (10/10)
578 +- [x] Pas de `|safe` ni `x-html` dans les templates
597 579
598 580 ### Avant chaque Release
599 581
582 +- [ ] CVE `lettre` corrigée (FUI-DEP-1)
600 583 - [ ] Body digest vérifié dans les HTTP signatures (F-CRYPTO-1)
601 584 - [ ] Replay protection via Date header (F-CRYPTO-2)
602 585 - [ ] Placeholder JWT secret supprimé (F-NET-5)
603 586 - [ ] Body size limits sur les inbox routes (F-NET-1)
604 587 - [ ] Auth middleware sur les invite routes (F-NET-3)
588 +- [ ] Claims extraits sur les 4 POST admin destructifs (FUI-AUTH-1)
589 +- [ ] Rate limiting sur SSE endpoint (FUI-DOS-1)
605 590
606 591 ---
607 592
608 593 ## Conclusion
609 594
610 -La crate `rustwarden-fediverse` présente un **bon niveau de conformité** aux recommandations ANSSI pour une crate nouvellement extraite. Les fondamentaux sont solides : pas de raw SQL, pas d'`unsafe` en production, secrets en env vars, CSPRNG pour la crypto, SSRF guard sur les fetches sortants, trust policy appliquée avant tout traitement.
595 +L'ajout de la crate `rustwarden-fediverse-ui` (3 329 lignes Rust + 1 347 lignes templates) introduit une surface d'attaque UI significative mais **globalement bien gérée**. Les points positifs :
611 596
612 -Le **finding critique** est le fallback silencieux sur un JWT secret placeholder (`F-NET-5`) qui doit être corrigé avant tout merge en branche principale. Les **findings high** (vérification du digest HTTP, protection contre le replay, et routes invite sans auth) sont des surfaces d'attaque connues du protocole ActivityPub qui doivent être adressées avant une mise en production fédérée.
597 +- **CSRF** : 10/10 POST handlers appliquent `csrf::validate_csrf_token`. Couverture parfaite.
598 +- **XSS** : Askama échappe par défaut, aucun bypass (`|safe`, `x-html`, `innerHTML`). 0 vecteur XSS.
599 +- **Injection SQL** : tout SeaORM, 0 raw SQL, 0 concaténation de chaîne.
600 +- **Secrets** : aucun secret en dur, CSRF tokens générés côté serveur, clés privées jamais exposées dans les DTO.
601 +- **Autorisation admin** : le middleware `AdminService` couvre toutes les routes `/admin/*` et vérifie le rôle `admin`.
613 602
614 -Le code est globalement bien structuré, les erreurs sont propagées via `Result` + `?`, et la couverture de tests (unitaires + e2e) est significative.
603 +Les **5 nouveaux findings sur fediverse-ui** (`FUI-AUTH-1`, `FUI-DOS-1`, `FUI-CSP-1`, `FUI-NET-1`, `FUI-TEST-1`) + 1 CVE (`FUI-DEP-1`) portent le total à **21 findings** (2 Critical, 4 High, 7 Medium, 5 Low, 3 Info) pour un score global de **70%** (en baisse par rapport aux 80% du rapport précédent, principalement à cause de la CVE lettre et de la surface UI non couverte par les tests).
615 604
616 -**Note importante** : aucun finding n'a été introduit par le refactor de migrations effectué le 2026-05-12 (fusion des migrations fediverse en `m20260510_000009_fediverse_interop` côté core et `m20260512_000001_fediverse_protocol_schema` côté fediverse, table de tracking dédiée `seaql_fediverse_migrations`). Tous les findings sont des dettes techniques pré-existantes de la phase d'écriture initiale de la crate, à l'exception de la migration fusionnée qui utilise correctement le DSL `Query::insert/delete` (renforcement positif).
605 +Les **9 findings existants** sur `rustwarden-fediverse` sont tous **INCHANGÉS** depuis le 2026-05-13 — aucune remédiation n'a été appliquée.
606 +
607 +Le **finding le plus urgent** est la CVE `lettre` (FUI-DEP-1, CVSS 9.1) publiée aujourd'hui : un simple `cargo update -p lettre` (après bump `Cargo.toml`) la résout en quelques secondes.
617 608
618 609 ---
619 610
620 -**Prochaine revue recommandée** : après implémentation des corrections priorité 1 et 2.
611 +**Prochaine revue recommandée** : après implémentation des corrections priorité 1 (FUI-DEP-1 + F-NET-5) et priorité 2 (F-CRYPTO-1/2, F-NET-3, FUI-AUTH-1).
612 +
613 +**Documents liés** :
614 +- Plan d'action : [docs/plans/2026-05-14-fediverse-security-and-guard.md](plans/2026-05-14-fediverse-security-and-guard.md)
615 +- Référentiel ANSSI historique RustCloud : [docs/ANSSI_COMPLIANCE_REPORT.md](ANSSI_COMPLIANCE_REPORT.md)
A docs/plans/2026-05-14-fediverse-security-and-guard.md
+602 -0
@@ -0,0 +1,602 @@
1 +# Plan — Durcissement sécurité fediverse + crate `rustwarden-fediverse-guard`
2 +
3 +> **Date** : 2026-05-14
4 +> **Branche** : `fedivers`
5 +> **Statut** : à approuver
6 +> **Documents liés** : [docs/ANSSI_FEDIVERSE_COMPLIANCE.md](../ANSSI_FEDIVERSE_COMPLIANCE.md), [docs/ANSSI_COMPLIANCE_REPORT.md](../ANSSI_COMPLIANCE_REPORT.md)
7 +
8 +## Contexte
9 +
10 +L'audit de conformité ANSSI **élargi** (2026-05-14) couvre désormais les **2 crates fediverse** (`rustwarden-fediverse` + `rustwarden-fediverse-ui`). Il identifie **21 findings** : 2 Critical, 4 High, 7 Medium, 5 Low, 3 Info. Score global : **70%** (en baisse par rapport aux 80% du 2026-05-13 dû à une nouvelle CVE Critical et à l'élargissement du périmètre).
11 +
12 +`cargo audit` signale **1 CVE Critical** (RUSTSEC-2026-0141 sur `lettre 0.11.19`, CVSS 9.1, publiée le 2026-05-14). `cargo clippy --all-features` reste vert (10 warnings cosmétiques). Les fondamentaux sont sains (RSA-2048 + OsRng, SSRF guard, pas d'unsafe en prod, secrets en env vars, CSRF 10/10 sur fediverse-ui, 0 vecteur XSS). Mais :
13 +
14 +- **FUI-DEP-1** (Critical) : CVE TLS hostname verification désactivée sur `lettre 0.11.19` (backend Boring TLS) — fix trivial via `cargo update -p lettre` vers ≥ 0.11.22.
15 +- **F-NET-5** (Critical) : `placeholder_auth_config()` ([crates/rustwarden-fediverse/src/routes.rs:107-126](../../crates/rustwarden-fediverse/src/routes.rs#L107-L126)) crée un JWT secret prévisible `"0".repeat(64)` en fallback silencieux si les env vars manquent. Forgery JWT triviale en cas de mauvaise config.
16 +- **F-CRYPTO-1/2** (High) : `parse_and_verify_cavage` ignore le body (digest non vérifié) et ne valide pas la fenêtre temporelle du header `Date` — un MITM ou un replay peut injecter des activités AP signées sous une identité légitime.
17 +- **F-NET-3** (High) : 5 endpoints invite acceptent `Option<Extension<Claims>>` avec fallback vers un user système — escalade de privilèges sans authentification.
18 +- **FUI-AUTH-1** (High, **nouveau**) : 4 POST admin destructifs dans `rustwarden-fediverse-ui` (`delete_trust_entry`, `revoke_remote_session`, `kill_domain`, `revoke_team_invite`) n'extraient pas `Extension<Claims>` — aucune trace d'audit *« qui a fait quoi »*. Le middleware `AdminService` couvre l'auth mais la défense en profondeur manque.
19 +
20 +En parallèle, l'utilisateur veut une **surveillance temps-réel des abus fediverse** (signature failures répétées, replays détectés, fetch SSRF tentés, spam d'auth challenges, inbox abuse) avec ban automatique et UI admin. Le modèle est `gitrust-ssh-guard` ([/home/monsieurb/Documents/devel/gitrust/crates/gitrust-ssh-guard/](../../../gitrust/crates/gitrust-ssh-guard/)) : composition `BanManager` + `GuardStore` (Memory + Postgres + Hybrid) + détecteurs sur fenêtre glissante + sinks d'événements + UI admin.
21 +
22 +**Objectif final** : un framework fediverse qui (1) résiste aux attaques connues du protocole ActivityPub à la spec, (2) détecte et bloque les comportements anormaux en exploitation, (3) offre une visibilité admin temps-réel.
23 +
24 +## Vue d'ensemble — deux étapes séquentielles
25 +
26 +```
27 +ÉTAPE 1 — Durcissement code (ANSSI) ÉTAPE 2 — Surveillance runtime
28 +┌──────────────────────────────────┐ ┌────────────────────────────┐
29 +│ Lot 0: FUI-DEP-1 CVE lettre │ │ crates/rustwarden- │
30 +│ Lot 1: F-CRYPTO-1 body digest │ │ fediverse-guard/ │
31 +│ F-CRYPTO-2 date skew │ prérequis │ │
32 +│ Lot 2: F-NET-3 auth invite ├────────────►│ • protocole headless │
33 +│ FUI-AUTH-1 admin claims │ │ • UI admin (feature `ui`) │
34 +│ Lot 3: F-NET-1 body limit inbox │ │ • migrations SeaORM │
35 +│ F-NET-2 redirect sanitize │ │ • observer trait pluggé │
36 +│ F-NET-4 CSRF consent │ │ dans rustwarden-fediv. │
37 +│ F-ERR-1 expect→Result │ │ │
38 +│ FUI-DOS-1 SSE rate limit │ │ │
39 +│ Lot 4: F-NET-5 JWT hard-fail │ │ │
40 +│ Lot 5: Low (deny warnings, etc.) │ │ │
41 +│ Lot 6: FUI-TEST-1 tests admin │ │ │
42 +└──────────────────────────────────┘ └────────────────────────────┘
43 +```
44 +
45 +L'étape 1 conditionne l'étape 2 : F-CRYPTO-1/2 doivent être corrigés avant que `HttpSigVerifier` soit utilisable en production, condition nécessaire pour que le guard puisse fiabilement classifier "signature invalide" comme un signal d'abus.
46 +
47 +---
48 +
49 +# ÉTAPE 1 — Fix ANSSI Compliance
50 +
51 +## Séquence ordonnée par dépendance
52 +
53 +### Lot 0 — CVE lettre (Critical, fix immédiat ~5 min)
54 +
55 +**FUI-DEP-1 — Upgrade `lettre` vers ≥ 0.11.22**
56 +
57 +Modifier [Cargo.toml](../../Cargo.toml) workspace :
58 +```toml
59 +[workspace.dependencies]
60 +lettre = "0.11.22"
61 +```
62 +
63 +Puis :
64 +```bash
65 +cargo update -p lettre
66 +cargo audit # doit redescendre à 0 vuln
67 +cargo build --workspace --all-features
68 +cargo test --workspace
69 +```
70 +
71 +Aucun changement de code attendu : la CVE concerne l'implémentation interne TLS de lettre, pas son API publique. Si l'API a légèrement bougé, le compilateur le signalera.
72 +
73 +### Lot 1 — Foundations crypto (High, indépendantes)
74 +
75 +**F-CRYPTO-1 — Vérifier le digest du body**
76 +
77 +Fichier : [crates/rustwarden-fediverse/src/http_signatures.rs:154-171](../../crates/rustwarden-fediverse/src/http_signatures.rs#L154-L171)
78 +
79 +`parse_and_verify_cavage` reçoit `body: &[u8]` mais fait `let _ = body;`. Implémenter une fonction interne `validate_digest(digest_header: &str, body: &[u8])` qui :
80 +1. Parse le format `"SHA-256=<base64>"` (rejette les autres algos).
81 +2. Décode le base64 en `Vec<u8>`.
82 +3. Calcule `sha2::Sha256::digest(body)` et compare en constant-time via `subtle::ConstantTimeEq` (la dep `subtle = "2.6"` est déjà déclarée dans [Cargo.toml:35](../../crates/rustwarden-fediverse/Cargo.toml#L35) mais inutilisée — opportunité de fermer aussi le Low correspondant).
83 +4. Erreur `AppError::Validation` si mismatch.
84 +
85 +Appel : si `parsed.headers` contient `"digest"`, exiger sa présence dans la closure `headers` fournie et appeler `validate_digest`. Sinon, accepter (rétrocompatibilité avec serveurs AP qui ne signent pas toujours le digest).
86 +
87 +Tests à ajouter : digest valide accepté, digest manipulé rejeté, digest absent quand requis rejeté, algo non supporté rejeté.
88 +
89 +**F-CRYPTO-2 — Valider la fenêtre temporelle du header Date**
90 +
91 +Même fichier. Ajouter `validate_date_header(date_str: &str, skew_seconds: i64) -> Result<(), AppError>` :
92 +1. Parse RFC 2822 via `chrono::DateTime::parse_from_rfc2822` (déjà disponible dans les dépendances transitives).
93 +2. Calcule `(Utc::now() - parsed.with_timezone(&Utc)).num_seconds().abs()`.
94 +3. Erreur si `> skew_seconds`.
95 +
96 +Defaut : 300s (5 min, compatible Mastodon/Pleroma/Misskey).
97 +
98 +Appel : si `parsed.headers` contient `"date"`, valider via la closure `headers`. Skew configurable via `HttpSigVerifier::with_skew_seconds()` (nouvelle méthode builder).
99 +
100 +Tests : date dans la fenêtre acceptée, date hors fenêtre (passée et future) rejetée, date malformée rejetée.
101 +
102 +### Lot 2 — Auth strict sur actions destructives (High, indépendantes)
103 +
104 +**F-NET-3 — Imposer l'authentification sur les routes invite**
105 +
106 +Fichier : [crates/rustwarden-fediverse/src/invite_routes.rs:107-227](../../crates/rustwarden-fediverse/src/invite_routes.rs#L107-L227)
107 +
108 +5 handlers concernés :
109 +- `invite()` (POST `/api/v1/teams/{team_id}/members/invite-fediverse`)
110 +- `list_for_team()` (GET `/api/v1/teams/{team_id}/invites`)
111 +- `revoke()` (POST `.../invites/{invite_id}/revoke`)
112 +- `idp_list()` (GET `/api/v1/fediverse/invites?handle=...`)
113 +- `idp_accept()` / `idp_reject()` (POST `.../invites/{id}/{accept,reject}`)
114 +
115 +Remplacer `claims: Option<Extension<Claims>>` par `Extension(claims): Extension<Claims>` (extracteur strict — Axum renvoie automatiquement 401 si absent).
116 +
117 +Supprimer le fallback `system_actor_user_id` et le commentaire « intentional for the slice minimal ». Conserver une variante `system_invite()` privée si nécessaire pour les flows IdP-side où le contexte d'auth vient des sessions (vérifier avec lecture détaillée des handlers IdP).
118 +
119 +Tests : 401 sans JWT, 403 si JWT valide mais pas membre/admin de l'équipe ciblée (RBAC check), 200 avec admin légitime.
120 +
121 +**FUI-AUTH-1 — Extraire `Claims` sur les 4 POST admin destructifs**
122 +
123 +Fichier : [crates/rustwarden-fediverse-ui/src/handlers/admin.rs](../../crates/rustwarden-fediverse-ui/src/handlers/admin.rs)
124 +
125 +4 handlers concernés :
126 +- Ligne 732 : `delete_trust_entry`
127 +- Ligne 1194 : `revoke_remote_session`
128 +- Ligne 1224 : `kill_domain`
129 +- Ligne 1711 : `revoke_team_invite`
130 +
131 +Ajouter `Extension(claims): Extension<Claims>` dans la signature de chacun (le middleware `AdminService` injecte déjà Claims en extension — il faut juste les extraire). Puis logger `claims.sub` dans le tracing structuré :
132 +
133 +```rust
134 +tracing::info!(
135 + admin_id = %claims.sub,
136 + target_domain = %form.domain,
137 + action = "delete_trust_entry",
138 + "admin destructive action"
139 +);
140 +```
141 +
142 +Cohérent avec le pattern déjà appliqué dans `update_settings` (ligne 345), `add_trust_entry` (ligne 665), `create_team_invite` (ligne 1678).
143 +
144 +Tests à ajouter (premiers tests de `admin.rs` — voir aussi Lot 6 FUI-TEST-1) : 401 sans middleware AdminService, 200 avec admin, vérification du log structured émis.
145 +
146 +### Lot 3 — Quick wins Medium (toutes indépendantes)
147 +
148 +**F-NET-1 — Body limit sur POST /inbox**
149 +
150 +Fichier : [crates/rustwarden-fediverse/src/routes.rs:58-71](../../crates/rustwarden-fediverse/src/routes.rs#L58-L71). Ajouter `DefaultBodyLimit::max(1 * 1024 * 1024)` (1 MiB, aligné Mastodon) sur les routes `/inbox` et `/fediverse/auth/inbox`.
151 +
152 +**F-NET-2 — Sanitize `redirect_url` dans le flow auth**
153 +
154 +Fichiers : [crates/rustwarden-fediverse/src/auth_routes.rs:55](../../crates/rustwarden-fediverse/src/auth_routes.rs#L55), [crates/rustwarden-fediverse/src/services/fediverse_auth_service.rs:74,142](../../crates/rustwarden-fediverse/src/services/fediverse_auth_service.rs).
155 +
156 +Fonction `validate_redirect_url(url: &str, allowed_host: &str)` :
157 +- Parse via `url::Url::parse`.
158 +- Refuse scheme ∉ {http, https}.
159 +- Refuse `host_str() != allowed_host` (où `allowed_host = state.config.domain()`).
160 +- Refuse les `..` et autres patterns suspects (déjà couvert par parser url, mais explicite).
161 +
162 +Appel : au début de `FediverseAuthService::initiate` si `redirect_url.is_some()`.
163 +
164 +Vérifier aussi côté UI [crates/rustwarden-fediverse-ui/src/handlers/user_auth.rs](../../crates/rustwarden-fediverse-ui/src/handlers/user_auth.rs) que les templates de consent n'injectent pas la `redirect_url` brute dans un attribut HTML sans escape Askama (Askama échappe par défaut — vérifier qu'aucun `|safe` n'est en jeu).
165 +
166 +**F-NET-4 — Enforcer CSRF sur `/api/v1/fediverse/auth/consent`**
167 +
168 +Fichier : [crates/rustwarden-fediverse/src/auth_routes.rs:252-277](../../crates/rustwarden-fediverse/src/auth_routes.rs#L252-L277).
169 +
170 +Vérifier l'existence d'un middleware `ValidateCsrfToken` dans [crates/rustwarden-core/src/middleware/csrf.rs](../../crates/rustwarden-core/src/middleware/csrf.rs). Si présent, l'appliquer en layer sur la route consent. Sinon, valider manuellement le token CSRF depuis la session contre le `consent_body.csrf_token`.
171 +
172 +**F-ERR-1 — Remplacer `expect()` par propagation `Result`**
173 +
174 +Fichier : [crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs:272](../../crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs#L272).
175 +
176 +Changer `AlwaysAcceptVerifier::with_resolver(db: DatabaseConnection) -> Self` en `-> Result<Self, AppError>`. Adapter le caller dans [routes.rs:35](../../crates/rustwarden-fediverse/src/routes.rs#L35).
177 +
178 +**FUI-DOS-1 — Rate limiting sur le SSE endpoint**
179 +
180 +Fichier : [crates/rustwarden-fediverse-ui/src/routes.rs](../../crates/rustwarden-fediverse-ui/src/routes.rs).
181 +
182 +Route concernée : `GET /login/fediverse/sse/{challenge_id}` (publique, sans auth).
183 +
184 +Ajouter une limite de concurrence pour éviter qu'un attaquant épuise le pool DB en ouvrant des milliers de connexions SSE de 5 minutes :
185 +
186 +```rust
187 +use tower::limit::ConcurrencyLimitLayer;
188 +
189 +Router::new()
190 + // ... autres routes ...
191 + .route(
192 + "/login/fediverse/sse/{challenge_id}",
193 + get(user_auth::sse_status).layer(ConcurrencyLimitLayer::new(100)),
194 + )
195 +```
196 +
197 +Plafond 100 connexions concurrentes par instance — couvre largement les usages légitimes (un user n'a qu'un onglet de login ouvert) tout en bornant la surface DoS.
198 +
199 +### Lot 4 — Refactor structurel critique (F-NET-5)
200 +
201 +**Dépendance** : tous les lots précédents, plus un vrai `HttpSigVerifier` production-grade (F-CRYPTO-1+2 corrigés). Sans ces deux, basculer en `router_with_auth` exposerait un trou plus large.
202 +
203 +Fichier : [crates/rustwarden-fediverse/src/routes.rs:54,107-126](../../crates/rustwarden-fediverse/src/routes.rs) + [app/src/main.rs](../../app/src/main.rs).
204 +
205 +Actions :
206 +1. **Supprimer** `placeholder_auth_config()` (lignes 107-126).
207 +2. **Déprécier** `router()` et `router_with()` avec attribut `#[deprecated(since = "0.2.0", note = "use router_with_auth with explicit AuthConfig + production HttpSigVerifier")]`.
208 +3. **Modifier `app/src/main.rs`** :
209 + - Construire `auth_config: Arc<AuthConfig> = Arc::new(AuthConfig::from_env()?)` (propager l'erreur jusqu'à `fn main() -> anyhow::Result<()>`).
210 + - Construire `verifier: Arc<dyn SignatureVerifier> = Arc::new(HttpSigVerifier::with_resolver(db.clone()))` (vrai vérificateur, plus `AlwaysAcceptVerifier` qui acceptait toute signature).
211 + - Construire `resolver: Arc<dyn LocalUserResolver> = Arc::new(SeaOrmLocalUserResolver::new(db.clone()))`.
212 + - Appeler `router_with_auth(fediverse_config, resolver, verifier, auth_config)`.
213 +4. **Adapter les tests e2e** ([crates/rustwarden-fediverse/tests/e2e_fediverse.rs](../../crates/rustwarden-fediverse/tests/e2e_fediverse.rs), [crates/rustwarden-fediverse/tests/fediverse_interop.rs](../../crates/rustwarden-fediverse/tests/fediverse_interop.rs)) qui appellent `router()` — exposer un helper `test_router()` dans la crate qui construit un `AuthConfig` de test inline (vrai secret aléatoire généré par `rand::rng()`, jamais zéro).
214 +
215 +Côté testbed Docker ([tests/fediverse-testbed/env/*.env](../../tests/fediverse-testbed/env/)) : vérifier que `JWT_SECRET` est bien défini dans les 3 fichiers d'env (alpha/beta/gamma). Sinon, ajouter une valeur de 64 chars aléatoires distincte par instance.
216 +
217 +### Lot 5 — Hygiène Low
218 +
219 +- `#![deny(warnings)]` dans [crates/rustwarden-fediverse/src/lib.rs:1](../../crates/rustwarden-fediverse/src/lib.rs#L1), [crates/rustwarden-fediverse-ui/src/lib.rs:1](../../crates/rustwarden-fediverse-ui/src/lib.rs#L1), et [crates/rustwarden-fediverse/migration/src/lib.rs:1](../../crates/rustwarden-fediverse/migration/src/lib.rs#L1).
220 +- 4 commentaires `// SAFETY:` manquants dans [crates/rustwarden-fediverse/tests/e2e_fediverse.rs](../../crates/rustwarden-fediverse/tests/e2e_fediverse.rs) lignes 321, 323, 477, 479.
221 +- Cleanup `templates/components/fediverse/` orphelin dans rustwarden-ui (dossier vide non tracké).
222 +- Migration des clés i18n `fediverse.*` de [crates/rustwarden-ui/locales/en.yml:256-851](../../crates/rustwarden-ui/locales/en.yml#L256) vers `crates/rustwarden-fediverse-ui/locales/en.yml` (idem fr/de/es). À faire en dernier car touche les 4 fichiers locales.
223 +- **FUI-NET-1** : `DefaultBodyLimit::max(8 * 1024)` sur `POST /login/fediverse` dans [crates/rustwarden-fediverse-ui/src/routes.rs](../../crates/rustwarden-fediverse-ui/src/routes.rs) — formulaires < 4 KiB, 8 KiB est confortable.
224 +- **FUI-CSP-1** : externaliser le script SSE inline de [templates/login_waiting.html:20-76](../../crates/rustwarden-fediverse-ui/templates/login_waiting.html#L20) dans un fichier `/static/js/fediverse-sse.js`, passer le `challenge_id` via `data-challenge-id`. Permet de durcir la CSP globale (suppression de `unsafe-inline` à terme, obs. 459 CLAUDE.md).
225 +
226 +### Lot 6 — Couverture de tests `rustwarden-fediverse-ui` (FUI-TEST-1)
227 +
228 +`admin.rs` a 2018 lignes et **0 test**. Sans test, FUI-AUTH-1 et FUI-DOS-1 risquent de régresser silencieusement au prochain refactor. Cibler une couverture minimale :
229 +
230 +- Tests CSRF : pour chaque POST handler, un test qui POST sans token CSRF valide et attend 403.
231 +- Tests d'autorisation : pour chaque admin handler, un test sans `Extension<Claims>` qui attend 401/403, et un test avec Claims admin qui attend 200/302.
232 +- Tests d'audit trail : vérifier que le tracing `tracing_test` capture bien `admin_id = <uuid>` pour les 4 actions destructives (FUI-AUTH-1).
233 +
234 +Outils : `tower::ServiceExt::oneshot` pour invoquer le router en mémoire (pas besoin de tokio listener), `axum::body::Body`, `axum_test` ou direct `Request::builder()`.
235 +
236 +Cible : ≥ 1 test par handler = 25 tests minimum. ~400 lignes de code de test.
237 +
238 +## Vérification étape 1
239 +
240 +```bash
241 +# 1. Build et tests sur tout le workspace
242 +cargo build --workspace --all-features
243 +cargo test --workspace --all-features --no-run # compilation tests
244 +cargo clippy --workspace --all-features -- -D warnings # zéro warning autorisé
245 +cargo audit # toujours 0 CVE
246 +
247 +# 2. Tests unitaires fediverse (devraient tous passer)
248 +cargo test -p rustwarden-fediverse --lib
249 +
250 +# 3. Tests e2e contre testbed Docker (testbed rebuilt avec nouveaux secrets)
251 +cd tests/fediverse-testbed && ./scripts/teardown.sh && docker compose build --no-cache && ./scripts/setup.sh && cd ../..
252 +cargo test -p rustwarden-fediverse --test e2e_fediverse -- --include-ignored --test-threads=1
253 +```
254 +
255 +## Critères d'acceptation étape 1
256 +
257 +- [x] `docs/ANSSI_FEDIVERSE_COMPLIANCE.md` mis à jour : score passe de 70% à ≥ 98%, **0 Critical**, **0 High**, ≤ 1 Medium résiduel justifié.
258 +- [x] Build + clippy strict + audit OK.
259 +- [x] **`cargo audit` revient à 0 CVE** (FUI-DEP-1 résolue par `lettre >= 0.11.22`).
260 +- [x] Tests e2e fediverse passent ≥ 30/32 (les 2 flaky inter-tests pré-existantes acceptables sous `--test-threads=1` répété).
261 +- [x] Tests unitaires `rustwarden-fediverse-ui` : ≥ 25 tests CSRF + auth + audit trail (vs 6 actuellement).
262 +- [x] `app/src/main.rs` n'utilise plus `router()` ni `router_with()`, mais `router_with_auth()`.
263 +- [x] `placeholder_auth_config()` supprimé du code source.
264 +- [x] `AlwaysAcceptVerifier` toujours présent dans la crate (utile en dev/test) mais **plus jamais wiré** dans `app/src/main.rs`.
265 +- [x] Les 4 handlers admin destructifs émettent un log structured `admin_id = <uuid>` à chaque appel (FUI-AUTH-1).
266 +
267 +---
268 +
269 +# ÉTAPE 2 — Crate `rustwarden-fediverse-guard`
270 +
271 +## Architecture cible
272 +
273 +```
274 +crates/rustwarden-fediverse-guard/
275 +├── Cargo.toml # name = "rustwarden-fediverse-guard"
276 +│ # features { default=["ui"], ui=[...], persistence-postgres=[...] }
277 +├── src/
278 +│ ├── lib.rs # API publique
279 +│ ├── config.rs # GuardConfig + FEDIVERSE_GUARD_* env vars
280 +│ ├── events.rs # GuardEvent enum (10 variantes AP-spécifiques)
281 +│ ├── ban.rs # BanManager — décision allow/deny par domaine ou actor URI
282 +│ ├── observer.rs # trait FediverseEventObserver (impl générique)
283 +│ ├── runtime.rs # GuardHandles bootstrap
284 +│ ├── detector/
285 +│ │ ├── mod.rs
286 +│ │ ├── signature_failures.rs # N sig invalides depuis domaine en T secondes
287 +│ │ ├── replay_attempts.rs # M nonces dupliqués depuis domaine
288 +│ │ ├── fetch_ssrf.rs # K tentatives fetch IP privée
289 +│ │ ├── inbox_abuse.rs # body > seuil ou type Activity inconnu répété
290 +│ │ └── auth_spam.rs # P challenges initiés sans consume depuis domaine
291 +│ ├── store/
292 +│ │ ├── mod.rs # trait GuardStore async (12 méthodes)
293 +│ │ ├── memory.rs # DashMap hot-path (lecture O(1))
294 +│ │ ├── postgres.rs # SeaORM write-through
295 +│ │ └── hybrid.rs # MemoryStore + Postgres write-through async + rehydrate au boot
296 +│ ├── sinks.rs # GuardEventSink trait + TracingSink + FileSink + MultiSink
297 +│ └── ui/ # feature-gated `ui`
298 +│ ├── mod.rs
299 +│ ├── handlers.rs # admin handlers (dashboard, bans list, events, manual ban/lift)
300 +│ ├── routes.rs # default_routes() — /admin/fediverse/guard/*
301 +│ ├── dashboard.rs # GuardDashboardCard pour /admin/fediverse hub
302 +│ └── dto.rs # DTOs pour templates
303 +├── migration/
304 +│ ├── src/
305 +│ │ ├── lib.rs # FediverseGuardMigrator (table tracking dédiée: seaql_fediverse_guard_migrations)
306 +│ │ ├── main.rs # bin migrate-fediverse-guard
307 +│ │ └── m20260514_000001_fediverse_guard_schema.rs # 3 tables fusionnées
308 +├── templates/ # feature-gated `ui`, askama.toml dirs partagés
309 +│ └── admin/guard/
310 +│ ├── dashboard.html
311 +│ ├── bans.html
312 +│ ├── events.html
313 +│ ├── manual_ban_form.html
314 +│ └── threshold_settings.html
315 +├── examples/
316 +│ └── headless_only.rs # exemple d'intégration sans UI
317 +└── tests/
318 + ├── ban_manager.rs # tests unitaires BanManager
319 + ├── detector_*.rs # un fichier par détecteur
320 + └── e2e_guard.rs # tests e2e avec testbed Docker
321 +```
322 +
323 +**Volume cible** : ~3500 lignes Rust, miroir de `gitrust-ssh-guard` (~3343 lignes).
324 +
325 +## Crate unique avec UI optionnelle
326 +
327 +Sur ta préférence, l'UI vit **dans la même crate** que le moteur headless, sous une feature Cargo :
328 +
329 +```toml
330 +[features]
331 +default = ["ui"]
332 +ui = ["dep:askama", "dep:axum-extra", "dep:rustwarden-ui"]
333 +```
334 +
335 +- Déploiement standard : `cargo build` → guard + UI admin compilés.
336 +- Déploiement headless (CI sans rendering, agent dédié sécurité) : `cargo build --no-default-features` → uniquement le moteur de détection et la persistance, pas de routes UI.
337 +
338 +Conséquence : les modules `ui/` et le dossier `templates/` sont gardés derrière `#[cfg(feature = "ui")]`. Le `lib.rs` réexporte conditionnellement.
339 +
340 +## Schéma SQL — 3 tables
341 +
342 +Migration unique fusionnée `m20260514_000001_fediverse_guard_schema.rs`, sur le modèle de [crates/rustwarden-fediverse/migration/src/m20260512_000001_fediverse_protocol_schema.rs](../../crates/rustwarden-fediverse/migration/src/m20260512_000001_fediverse_protocol_schema.rs) — table de tracking dédiée `seaql_fediverse_guard_migrations` (cohérent avec la pratique adoptée le 2026-05-12).
343 +
344 +```sql
345 +-- 1. Événements bruts (forensique + entrée des détecteurs)
346 +CREATE TABLE fediverse_guard_events (
347 + id UUID PRIMARY KEY,
348 + event_type TEXT NOT NULL, -- 'sig_invalid','replay','ssrf_attempt','inbox_oversize','auth_spam',...
349 + domain TEXT NOT NULL, -- normalisé (lowercase, sans www, sans trailing slash)
350 + actor_uri TEXT, -- AP id quand disponible
351 + remote_identity_id UUID, -- FK remote_identities.id, nullable
352 + severity TEXT NOT NULL, -- 'info','low','medium','high','critical'
353 + http_status INT,
354 + details JSONB, -- payload structuré spécifique au détecteur
355 + created_at TIMESTAMPTZ NOT NULL
356 +);
357 +CREATE INDEX fediverse_guard_events_domain_time_idx ON fediverse_guard_events (domain, created_at DESC);
358 +CREATE INDEX fediverse_guard_events_type_time_idx ON fediverse_guard_events (event_type, created_at DESC);
359 +
360 +-- 2. Bans actifs / historiques (auto + manuels)
361 +CREATE TABLE fediverse_guard_bans (
362 + id UUID PRIMARY KEY,
363 + domain TEXT NOT NULL,
364 + actor_uri TEXT, -- nullable : ban par domaine OU par acteur
365 + reason TEXT NOT NULL,
366 + severity TEXT NOT NULL, -- 'suspend','silence','rate_limit'
367 + triggered_by TEXT NOT NULL, -- 'auto:<detector_name>' | 'manual:<user_uuid>'
368 + banned_at TIMESTAMPTZ NOT NULL,
369 + expires_at TIMESTAMPTZ, -- NULL = permanent
370 + lifted_at TIMESTAMPTZ,
371 + lifted_by UUID -- FK users.id ON DELETE SET NULL
372 +);
373 +CREATE INDEX fediverse_guard_bans_active_idx ON fediverse_guard_bans (domain, lifted_at) WHERE lifted_at IS NULL;
374 +
375 +-- 3. ACL (allow / deny manuels, précèdent l'auto-ban)
376 +CREATE TABLE fediverse_guard_acl (
377 + id UUID PRIMARY KEY,
378 + kind TEXT NOT NULL, -- 'allow' | 'deny'
379 + domain TEXT NOT NULL,
380 + reason TEXT,
381 + created_by UUID NOT NULL, -- FK users.id ON DELETE CASCADE
382 + created_at TIMESTAMPTZ NOT NULL,
383 + updated_at TIMESTAMPTZ NOT NULL,
384 + UNIQUE (kind, domain)
385 +);
386 +```
387 +
388 +**Réutilisation des tables fediverse existantes** :
389 +- `fediverse_blocked_domains` (existante dans `m20260512_000001`) **reste** : elle exprime la *politique trust* de l'instance (intentionnel admin). `fediverse_guard_bans` exprime les *bans dynamiques détectés en exploitation*. Pas de fusion — deux concepts distincts. Le `BanManager` consultera **les deux** au moment du check.
390 +- `fediverse_trusted_domains` (allowlist trust) reste aussi : utilisée par `BanManager` comme bypass — un domaine trusted ne peut être auto-banni (même garde-fou que l'ACL allow dans gitrust-ssh-guard).
391 +
392 +## API publique — `lib.rs`
393 +
394 +```rust
395 +//! Real-time abuse detection and ban management for rustwarden-fediverse.
396 +
397 +pub mod ban;
398 +pub mod config;
399 +pub mod detector;
400 +pub mod events;
401 +pub mod observer;
402 +pub mod runtime;
403 +pub mod sinks;
404 +pub mod store;
405 +
406 +#[cfg(feature = "ui")]
407 +pub mod ui;
408 +
409 +// Re-exports
410 +pub use ban::BanManager;
411 +pub use config::{GuardConfig, GuardConfigError};
412 +pub use events::{GuardEvent, GuardEventSink};
413 +pub use observer::FediverseEventObserver;
414 +pub use runtime::GuardHandles;
415 +pub use store::{GuardStore, HybridStore, MemoryStore, PostgresStore};
416 +
417 +#[cfg(feature = "ui")]
418 +pub use ui::{default_routes, dashboard_card};
419 +```
420 +
421 +## Pattern d'intégration côté `app/src/main.rs`
422 +
423 +```rust
424 +#[cfg(feature = "fediverse-guard")]
425 +let (builder, guard_observer) = {
426 + let guard_config = rustwarden_fediverse_guard::GuardConfig::from_env()?;
427 + let guard = rustwarden_fediverse_guard::GuardHandles::new(guard_config, db.clone()).await?;
428 + let observer: Arc<dyn FediverseEventObserver> = Arc::new(guard.observer());
429 + (
430 + builder
431 + .merge_routes(rustwarden_fediverse_guard::ui::default_routes()) // si feature ui
432 + .add_dashboard_card(rustwarden_fediverse_guard::ui::dashboard_card())
433 + .register_fediverse_observer(observer),
434 + guard,
435 + )
436 +};
437 +```
438 +
439 +Le builder de l'app expose une **nouvelle méthode** `register_fediverse_observer(Arc<dyn FediverseEventObserver>)` dans [crates/rustwarden-core/src/app.rs](../../crates/rustwarden-core/src/app.rs), à côté de `register_protected_prefix` et `register_revocation_checker` déjà existants.
440 +
441 +Le **trait** `FediverseEventObserver` est défini **dans `rustwarden-fediverse`** (pas dans guard) pour éviter le couplage circulaire :
442 +
443 +```rust
444 +// crates/rustwarden-fediverse/src/observer.rs (nouveau)
445 +#[async_trait]
446 +pub trait FediverseEventObserver: Send + Sync {
447 + async fn on_signature_failure(&self, ctx: SignatureFailureCtx);
448 + async fn on_inbound_activity(&self, ctx: InboundActivityCtx);
449 + async fn on_outbound_fetch(&self, ctx: OutboundFetchCtx);
450 + async fn on_auth_challenge(&self, ctx: AuthChallengeCtx);
451 + async fn on_invite_action(&self, ctx: InviteActionCtx);
452 +}
453 +```
454 +
455 +Tous les hooks sont **fire-and-forget** : ne bloquent pas le chemin chaud, ne propagent pas d'erreur. Le guard fait ce qu'il veut de l'événement (logger, agréger, déclencher un ban auto).
456 +
457 +Les points d'émission dans `rustwarden-fediverse` :
458 +- [src/inbox.rs](../../crates/rustwarden-fediverse/src/inbox.rs) après échec de signature → `on_signature_failure`.
459 +- [src/http_signatures.rs](../../crates/rustwarden-fediverse/src/http_signatures.rs) après replay détecté (nouvelle vérification du F-CRYPTO-2) → `on_inbound_activity` avec `kind = "replay_attempt"`.
460 +- [src/services/fediverse_signal_service.rs](../../crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs) après SSRF guard hit → `on_outbound_fetch`.
461 +- [src/services/fediverse_auth_service.rs](../../crates/rustwarden-fediverse/src/services/fediverse_auth_service.rs) à chaque `initiate` → `on_auth_challenge`.
462 +- [src/invite_routes.rs](../../crates/rustwarden-fediverse/src/invite_routes.rs) à chaque accept/reject → `on_invite_action`.
463 +
464 +Côté guard, `BanManager::on_event(GuardEvent)` :
465 +1. Persiste l'événement via `store.insert_event()`.
466 +2. Vérifie l'ACL (allow → bypass, deny → no-op car déjà banni).
467 +3. Si trusted_domain → bypass.
468 +4. Pour chaque détecteur applicable, calcule le compteur sur fenêtre glissante et compare au seuil.
469 +5. Si seuil dépassé : crée un `BanEntry`, persiste via `store.insert_ban()`, émet `GuardEvent::DomainBanned`.
470 +
471 +## Configuration — variables d'environnement
472 +
473 +```
474 +FEDIVERSE_GUARD_ENABLED=true
475 +FEDIVERSE_GUARD_DRY_RUN=false # si true: émet logs mais ne bannit jamais
476 +FEDIVERSE_GUARD_STORE_BACKEND=hybrid # memory | postgres | hybrid (défaut)
477 +FEDIVERSE_GUARD_SIG_FAILURE_THRESHOLD=10 # N sig invalides
478 +FEDIVERSE_GUARD_SIG_FAILURE_WINDOW_SECS=300
479 +FEDIVERSE_GUARD_REPLAY_THRESHOLD=3
480 +FEDIVERSE_GUARD_REPLAY_WINDOW_SECS=600
481 +FEDIVERSE_GUARD_SSRF_THRESHOLD=5
482 +FEDIVERSE_GUARD_INBOX_ABUSE_THRESHOLD=20
483 +FEDIVERSE_GUARD_AUTH_SPAM_THRESHOLD=30
484 +FEDIVERSE_GUARD_AUTO_BAN_DURATION_SECS=3600 # 1h défaut, 0 = permanent
485 +FEDIVERSE_GUARD_EVENTS_RETENTION_DAYS=90
486 +FEDIVERSE_GUARD_PRUNE_INTERVAL_SECS=3600
487 +FEDIVERSE_GUARD_LOG_TARGET=stderr # stderr | file | both
488 +FEDIVERSE_GUARD_LOG_FILE=/var/log/rustwarden-fediverse-guard.json
489 +```
490 +
491 +## UI admin — sous feature `ui`
492 +
493 +5 écrans Askama dans `templates/admin/guard/` :
494 +1. **Dashboard** (`/admin/fediverse/guard`) — stats globales : nb événements 24h par type, top 10 domaines actifs, nb bans actifs, courbe d'évolution.
495 +2. **Events list** (`/admin/fediverse/guard/events`) — table paginée des événements avec filtres (event_type, domain, severity, période).
496 +3. **Bans list** (`/admin/fediverse/guard/bans`) — bans actifs + historique, lift via POST.
497 +4. **Manual ban form** (POST `/admin/fediverse/guard/bans`) — ban manuel par domaine ou actor_uri avec durée et raison.
498 +5. **Thresholds settings** (`/admin/fediverse/guard/settings`) — override par domaine des seuils par défaut.
499 +
500 +Le hub `/admin/fediverse` existant dans [crates/rustwarden-fediverse-ui/src/handlers/admin.rs](../../crates/rustwarden-fediverse-ui/src/handlers/admin.rs) ajoute une carte **« Guard »** via la dashboard card exportée par `rustwarden-fediverse-guard::ui::dashboard_card()`. Aucune modification de `rustwarden-fediverse-ui` requise — le câblage se fait côté `app/src/main.rs` via `add_dashboard_card`.
501 +
502 +Templates héritent du même pattern que `rustwarden-fediverse-ui` : `askama.toml dirs = ["templates", "../rustwarden-ui/templates"]` pour réutiliser `base.html`, `components/navbar.html`, etc. Pas de duplication de helpers Askama.
503 +
504 +Authentification : Extension<Claims> obligatoire + check RBAC `admin` (réutilise le pattern de `admin.rs:49` `build_base_context_with_claims`).
505 +CSRF : middleware `csrf::ValidateCsrfToken` sur tous les POST.
506 +
507 +## Telemetry
508 +
509 +- **Tracing** : chaque `GuardEvent` émis via `tracing::warn!(target: "rustwarden_fediverse_guard", event = ?evt)`. Capturable par journald, console, OpenTelemetry.
510 +- **Sinks supplémentaires** : `FileSink` (JSON line append-only) pour intégration fail2ban-like, `MultiSink` pour fan-out. Configurables via `FEDIVERSE_GUARD_LOG_TARGET`.
511 +- **Hooks** : émet `RustwardenHooks::on_audit_event` ([crates/rustwarden-core/src/hooks.rs](../../crates/rustwarden-core/src/hooks.rs)) pour chaque ban auto/manuel → permet à l'app consumer d'envoyer vers SIEM externe, Slack, etc.
512 +
513 +## Vérification étape 2
514 +
515 +```bash
516 +# Build avec et sans feature ui (cas headless)
517 +cargo build -p rustwarden-fediverse-guard
518 +cargo build -p rustwarden-fediverse-guard --no-default-features
519 +
520 +# Migrations
521 +cargo run -p rustwarden-fediverse-guard --bin migrate-fediverse-guard -- up
522 +cargo run -p rustwarden-fediverse-guard --bin migrate-fediverse-guard -- status
523 +
524 +# Tests unitaires (BanManager, détecteurs, stores)
525 +cargo test -p rustwarden-fediverse-guard --lib
526 +
527 +# Tests e2e — scénarios d'abus contre testbed Docker
528 +cargo test -p rustwarden-fediverse-guard --test e2e_guard -- --include-ignored --test-threads=1
529 +```
530 +
531 +Scénarios e2e à valider :
532 +- Envoyer 11 signatures invalides depuis alpha → vérifier que beta auto-bannit alpha au 11e.
533 +- Ban manuel d'un domaine via UI → vérifier rejet 403 sur le prochain inbox de ce domaine.
534 +- Lift ban → vérifier que l'inbox accepte à nouveau.
535 +- ACL allow → vérifier qu'aucun auto-ban ne se déclenche même au-delà du seuil.
536 +- Dry-run mode → vérifier émission d'événements `WouldBan` sans ban effectif.
537 +- Persistance hybride → restart de l'app → bans toujours actifs (rehydrate depuis Postgres).
538 +
539 +## Critères d'acceptation étape 2
540 +
541 +- [x] Crate `rustwarden-fediverse-guard` ajoutée au workspace.
542 +- [x] Trait `FediverseEventObserver` défini dans `rustwarden-fediverse` et émis aux 5 points d'événement.
543 +- [x] Méthode `RustwardenBuilder::register_fediverse_observer` exposée.
544 +- [x] Build OK avec et sans feature `ui`.
545 +- [x] 5 détecteurs implémentés avec seuils configurables et tests unitaires (≥ 4 tests par détecteur).
546 +- [x] Backend hybride mémoire + Postgres, rehydrate au boot.
547 +- [x] UI admin fonctionnelle (5 écrans), CSRF + auth obligatoires.
548 +- [x] Tests e2e ≥ 6 scénarios passent contre testbed.
549 +- [x] Dashboard card affichée dans `/admin/fediverse`.
550 +- [x] Documentation `docs/fediverse-guard.md` : architecture, configuration, runbook.
551 +- [x] `cargo audit` toujours 0 CVE.
552 +- [x] `cargo clippy --all-features -- -D warnings` reste vert.
553 +
554 +---
555 +
556 +# Effort estimé
557 +
558 +| Étape | Lots | Lignes touchées | Effort |
559 +|---|---|---|---|
560 +| **1** | Lot 0 CVE lettre (FUI-DEP-1) | 1 (Cargo.toml) | 5 min |
561 +| **1** | Lot 1 crypto (F-CRYPTO-1/2) | ~100 | 1 jour |
562 +| **1** | Lot 2 auth strict (F-NET-3 + FUI-AUTH-1) | ~120 | 0.5 jour |
563 +| **1** | Lot 3 quick wins Medium (5 findings) | ~80 | 0.5 jour |
564 +| **1** | Lot 4 F-NET-5 refactor + tests adaptés | ~150 | 1 jour |
565 +| **1** | Lot 5 hygiène Low + i18n migration + FUI-NET-1 + FUI-CSP-1 | ~80 | 0.5 jour |
566 +| **1** | Lot 6 tests `rustwarden-fediverse-ui` (FUI-TEST-1) | ~400 | 1 jour |
567 +| **2** | Skeleton crate + Cargo.toml + lib.rs | ~200 | 0.5 jour |
568 +| **2** | Schéma SQL + migrator + bin | ~300 | 0.5 jour |
569 +| **2** | Store trait + 3 backends (memory/postgres/hybrid) | ~600 | 1.5 jour |
570 +| **2** | BanManager + ACL + tests | ~500 | 1 jour |
571 +| **2** | 5 détecteurs + tests unitaires | ~700 | 2 jours |
572 +| **2** | Trait observer + émission depuis fediverse + builder hook | ~300 | 1 jour |
573 +| **2** | UI admin (5 écrans + templates + handlers + i18n) | ~900 | 2 jours |
574 +| **2** | Tests e2e contre testbed + scénarios d'abus | ~400 | 1.5 jour |
575 +| **2** | Docs : fediverse-guard.md, runbook, schémas | ~200 lignes md | 0.5 jour |
576 +| **Total** | | ~4540 LoC | **~14 jours** |
577 +
578 +À ramener à ~10-12 jours en parallélisant la rédaction de la doc et la mise au point des détecteurs.
579 +
580 +---
581 +
582 +# Hors-scope explicites
583 +
584 +- **Pas de Redis** pour la persistance guard (le store hybride memory+Postgres suffit, cohérent avec le pattern existant).
585 +- **Pas d'OpenTelemetry** dans l'étape 2 : on émet via `tracing::*` standard, l'intégration OTel reste à la charge de l'app consumer (déjà documenté côté projet).
586 +- **Pas d'agent fail2ban natif** : la `FileSink` JSON est suffisante pour qu'un fail2ban externe la consomme.
587 +- **Pas de webhooks Slack/Discord natifs** : émission via `RustwardenHooks::on_audit_event`, l'app peut implémenter ses propres notifications.
588 +- **Pas de blocage par IP** : le guard travaille au niveau domaine/actor_uri (la spec AP impose la signature, donc la couche IP n'est pas pertinente — un MITM verra une IP différente de l'origine légitime).
589 +- **Pas de prise en charge des activités AP exotiques** au-delà de Mastodon/Pleroma/Misskey dans la première version (Lemmy, PeerTube, Funkwhale viendront après si besoin).
590 +- **Pas de mode multi-tenant** : un seul guard par instance Rustwarden (cohérent avec le reste de la stack).
591 +
592 +---
593 +
594 +# Critères de succès du plan global
595 +
596 +Après les deux étapes :
597 +
598 +1. Score ANSSI fediverse ≥ 98% (vs 80% actuel), 0 Critical, 0 High.
599 +2. Une instance Rustwarden fédérée résiste aux attaques connues du protocole AP (digest MITM, replay, JWT forgery, escalade invite, open redirect, CSRF, DoS body).
600 +3. L'admin a une visibilité temps-réel des abus et peut bannir manuellement en 1 clic.
601 +4. Tout abus répété est auto-bloqué selon des seuils configurables avant qu'un humain n'intervienne.
602 +5. Le code reste idiomatique Rust (zéro unwrap/expect en prod, zéro raw SQL, deny warnings actif), reproductible (cargo audit clean), testable (≥ 200 tests unitaires + e2e).
A docs/plans/cli-e2e-tests.md
+367 -0
@@ -0,0 +1,367 @@
1 +# Plan — Tests E2E automatisés du CLI rustwarden-cli
2 +
3 +## Context
4 +
5 +Le wizard `rustwarden-cli start-project` est entièrement interactif (dialoguer). Aujourd'hui aucun test ne vérifie qu'un projet généré **compile et démarre**. Plusieurs bugs ont été détectés à la main au cours des dernières heures qui auraient été détectés automatiquement :
6 +
7 +- `;` manquant dans le `main.rs` généré quand fediverse activé
8 +- `[workspace.package]` absent du `Cargo.toml` généré → crates inheritées ne compilent pas
9 +- Charset password contenant `$` → Docker Compose interprète comme variable
10 +- Charset password contenant `#` → DATABASE_URL parser tronque
11 +- Migration 9 absente de `core_migrations()` → `column ap_public_key_pem does not exist` au runtime
12 +- Validation lowercase manquante → `COMPOSE_PROJECT_NAME` rejeté par Docker
13 +
14 +**Outcome** : un test E2E qui, pour chaque combinaison `(ui, fediverse, fediverse-ui)` valide une **chaîne fonctionnelle complète**, pas juste un healthcheck :
15 +
16 +1. Génère un projet via le CLI
17 +2. Lance PostgreSQL pour ce projet
18 +3. Compile et démarre le binaire
19 +4. **Seed un utilisateur admin** (via le binaire `seed` du framework)
20 +5. **Healthcheck** : `GET /health` répond 200
21 +6. **Login UI** (scénarios avec UI) :
22 + - `GET /login` → page HTML 200 OK, contient le formulaire
23 + - `POST /login` avec credentials admin → redirect 302 + cookie de session
24 + - `GET /admin` avec le cookie → 200 OK (page admin accessible)
25 +7. **Login API** (scénarios headless) :
26 + - `POST /api/auth/login` JSON → 200 + JWT
27 + - `GET /api/me` avec `Authorization: Bearer <jwt>` → 200 + user data
28 +8. **Navigation fédération** (scénarios avec fediverse-ui) :
29 + - `GET /admin/fediverse` (hub) avec cookie session → 200
30 + - `GET /admin/fediverse/settings` → 200
31 +9. **Endpoints fédération protocole** (scénarios avec fediverse) :
32 + - `GET /.well-known/webfinger?resource=acct:admin@<domain>` → 200 + JSON
33 + - `GET /actors/admin` (Accept: application/activity+json) → 200 + JSON-LD
34 +10. Nettoie tout (process, container, dossier)
35 +
36 +Si toutes les variantes passent leurs étapes pertinentes, le CLI est sain. Si une variante échoue à l'étape N, le test révèle précisément quelle combinaison + quelle étape casse.
37 +
38 +---
39 +
40 +## Décisions actées (selon directive utilisateur)
41 +
42 +| Aspect | Choix |
43 +|---|---|
44 +| Profondeur | Génération + boot + `GET /health` (vérité runtime) |
45 +| Couverture | Matrice 3 axes UI / fediverse / fediverse-ui = 5 combinaisons valides |
46 +| SMTP / OAuth | Toujours désactivés dans tous les scénarios |
47 +| Credentials admin | Fixes : username `admin`, email `admin@localhost`, password `Admin12345!` |
48 +| Nom de DB | Nom du scénario lui-même (ex. `test_minimal`, `test_full`) |
49 +| Port d'écoute | Unique par scénario (3010..3014) pour permettre parallélisation |
50 +| Port PostgreSQL | Unique par scénario (5440..5444) |
51 +| Format config | TOML (idiomatique Rust, cohérent avec Cargo.toml) |
52 +
53 +---
54 +
55 +## Matrice : 5 combinaisons valides + étapes de validation
56 +
57 +| # | Nom scénario | UI | Fed | Fed-UI | Étapes de validation |
58 +|---|---|---|---|---|---|
59 +| 1 | `test_api_only` | ❌ | ❌ | ❌ | Health, Login API (JWT), `/api/me` |
60 +| 2 | `test_ui_only` | ✅ | ❌ | ❌ | Health, GET /login, POST /login, GET /admin |
61 +| 3 | `test_headless_fed` | ❌ | ✅ (Headless) | ❌ | Health, Login API, /api/me, WebFinger, GET /actors/admin |
62 +| 4 | `test_ui_fed_custom` | ✅ | ✅ (Headless) | ❌ | Health, login UI, GET /admin, WebFinger, GET /actors/admin |
63 +| 5 | `test_full` | ✅ | ✅ (WithReferenceUi) | ✅ | Health, login UI, GET /admin, GET /admin/fediverse (hub), GET /admin/fediverse/settings, WebFinger, GET /actors/admin |
64 +
65 +**Combinaison invalide explicitement exclue** : `(ui=N, fediverse=Y, fediverse-ui=Y)` — la crate `rustwarden-fediverse-ui` dépend de `rustwarden-ui` (pour `template_helpers` + `impl_template_response!`). Le CLI doit rejeter cette combinaison ; un 6ᵉ test négatif vérifie ce rejet : `test_invalid_fed_ui_without_ui` qui appelle le CLI avec cette config et asserte que ça échoue avec le bon message d'erreur.
66 +
67 +### Test d'admin seed (commun à tous les scénarios)
68 +
69 +Avant l'étape login, chaque scénario lance `cargo run -p rustwarden-core --bin seed` (avec `DATABASE_URL` pointant sur le PG du scénario) pour créer l'utilisateur admin avec les credentials fixes :
70 +- username = `admin`
71 +- email = `admin@localhost`
72 +- password = `Admin12345!`
73 +- role = administrateur
74 +
75 +Si le binaire `seed` n'expose pas ces paramètres en CLI ou via env vars, on l'invoque tel quel en supposant qu'il a un défaut configurable — à investiguer en phase 1 d'implémentation. Fallback : INSERT direct via `psql` avec hash bcrypt pré-calculé du password.
76 +
77 +---
78 +
79 +## Architecture cible
80 +
81 +```
82 +tools/rustwarden-cli/
83 +├── src/
84 +│ ├── start_project.rs (modif : support du flag --config)
85 +│ ├── templates.rs (modif : derive Serialize/Deserialize sur ProjectConfig)
86 +│ └── ...
87 +├── tests/
88 +│ ├── e2e_wizard.rs (NOUVEAU : test runner Rust)
89 +│ └── fixtures/
90 +│ ├── test_api_only.toml
91 +│ ├── test_ui_only.toml
92 +│ ├── test_headless_fed.toml
93 +│ ├── test_ui_fed_custom.toml
94 +│ └── test_full.toml
95 +```
96 +
97 +---
98 +
99 +## Étapes
100 +
101 +### 1. Ajouter le mode non-interactif `--config <path>` au wizard
102 +
103 +**Pourquoi** : `dialoguer` exige un TTY (validé par exploration). Pipe stdin impossible. Seule voie : un fichier de config TOML qui bypasse les `ask_*`.
104 +
105 +**Fichiers modifiés** :
106 +
107 +- [tools/rustwarden-cli/src/templates.rs](tools/rustwarden-cli/src/templates.rs) — ajouter `#[derive(Serialize, Deserialize)]` sur :
108 + - `ProjectConfig`
109 + - `SmtpConfig`
110 + - `OAuthWizardConfig`
111 + - `OAuthProviderConfig`
112 + - `FediverseMode`
113 +
114 + Ajouter aussi `serde = { version = "1", features = ["derive"] }` aux deps de `rustwarden-cli` si pas déjà présent (probable, déjà transitive via dialoguer/console).
115 +
116 + Ajouter `toml = "0.8"` aux deps.
117 +
118 +- [tools/rustwarden-cli/src/start_project.rs](tools/rustwarden-cli/src/start_project.rs) — modifier `pub fn run()` pour accepter un argument optionnel :
119 + ```rust
120 + pub fn run(config_path: Option<PathBuf>) -> Result<(), Box<dyn std::error::Error>> {
121 + let cfg = match config_path {
122 + Some(path) => load_config_from_toml(&path)?,
123 + None => run_interactive_wizard()?, // l'actuel `run` renommé
124 + };
125 + generate_project(cfg)
126 + }
127 +
128 + fn load_config_from_toml(path: &Path) -> Result<ProjectConfig, Box<dyn std::error::Error>> {
129 + let content = std::fs::read_to_string(path)?;
130 + let cfg: ProjectConfig = toml::from_str(&content)?;
131 + validate_config(&cfg)?; // rejette ui=N + fediverse-ui=Y
132 + Ok(cfg)
133 + }
134 + ```
135 +
136 +- [tools/rustwarden-cli/src/main.rs](tools/rustwarden-cli/src/main.rs) — ajouter le flag `--config` au sous-commande `start-project` via `clap`.
137 +
138 +**Validation à ajouter** : dans `validate_config()`, rejeter explicitement la combinaison `ui = false && fediverse_mode = WithReferenceUi`. Cette validation doit aussi exister dans le wizard interactif (cohérence) — à factoriser.
139 +
140 +### 2. Créer les 5 fixtures TOML
141 +
142 +Dans [tools/rustwarden-cli/tests/fixtures/](tools/rustwarden-cli/tests/fixtures/) — 5 fichiers, structure type :
143 +
144 +```toml
145 +# test_api_only.toml
146 +name = "test_api_only"
147 +app_display_name = "Test API Only"
148 +description = "Scenario E2E : API headless minimal"
149 +with_ui = false
150 +fediverse_mode = "None"
151 +fediverse_domain = ""
152 +auto_migrate = true
153 +port = 3010
154 +
155 +[database]
156 +name = "test_api_only"
157 +user = "test_user"
158 +password = "test_password_safe"
159 +
160 +[admin]
161 +username = "admin"
162 +email = "admin@localhost"
163 +password = "Admin12345!"
164 +
165 +# pas de section [smtp] ni [oauth] — désactivés
166 +```
167 +
168 +Adapter pour chaque scénario :
169 +- `test_ui_only`: `with_ui = true`, port 3011, db_name `test_ui_only`
170 +- `test_headless_fed`: `fediverse_mode = "Headless"`, fediverse_domain = `test_headless_fed.local`, port 3012
171 +- `test_ui_fed_custom`: `with_ui = true`, `fediverse_mode = "Headless"`, port 3013, fediverse_domain = `test_ui_fed_custom.local`
172 +- `test_full`: `with_ui = true`, `fediverse_mode = "WithReferenceUi"`, port 3014, fediverse_domain = `test_full.local`
173 +
174 +### 3. Test runner Rust : `tests/e2e_wizard.rs`
175 +
176 +**Pourquoi Rust et pas bash** : déjà dans l'écosystème cargo, `#[ignore]` natif pour skip si Docker absent, parallélisation gérée par cargo test, cleanup garantie via `Drop`, `reqwest` simple à utiliser pour les assertions HTTP, gestion native des cookies de session.
177 +
178 +**Pattern** :
179 +
180 +```rust
181 +#[test]
182 +#[ignore = "requires Docker, run with: cargo test -p rustwarden-cli --test e2e_wizard -- --ignored"]
183 +fn test_full() {
184 + let scenario = Scenario::load("test_full");
185 + let _guard = ScenarioGuard::new(&scenario);
186 +
187 + // 1. Generate
188 + cli_generate(&scenario).expect("CLI generation failed");
189 +
190 + // 2. Start PostgreSQL (port unique)
191 + docker_up(&scenario).expect("PG up failed");
192 +
193 + // 3. Build + run the generated binary in background (auto_migrate=true ⇒ tables créées au boot)
194 + let mut child = cargo_run(&scenario).expect("cargo run failed");
195 +
196 + // 4. Wait for /health = 200 (timeout 120s)
197 + poll_health(&scenario, Duration::from_secs(120))
198 + .expect("server did not become healthy");
199 +
200 + // 5. Seed admin (utilise le binaire seed du framework copié dans le projet)
201 + seed_admin(&scenario).expect("admin seed failed");
202 +
203 + // 6. UI login flow (scénarios with_ui)
204 + let session = if scenario.with_ui {
205 + let s = login_ui(&scenario, "admin", "Admin12345!")
206 + .expect("login UI failed");
207 + // GET /admin avec cookie ⇒ 200
208 + assert_get_with_cookie(&scenario, "/admin", &s, 200);
209 + Some(s)
210 + } else {
211 + // 6bis. API login flow (scénarios headless)
212 + let jwt = login_api(&scenario, "admin", "Admin12345!")
213 + .expect("login API failed");
214 + assert_get_with_jwt(&scenario, "/api/me", &jwt, 200);
215 + None
216 + };
217 +
218 + // 7. Fediverse-UI navigation (scénarios fediverse-ui only)
219 + if scenario.with_fediverse_ui {
220 + let s = session.as_ref().expect("with_fediverse_ui implies with_ui");
221 + assert_get_with_cookie(&scenario, "/admin/fediverse", s, 200);
222 + assert_get_with_cookie(&scenario, "/admin/fediverse/settings", s, 200);
223 + }
224 +
225 + // 8. Fediverse protocol endpoints (scénarios with_fediverse)
226 + if scenario.with_fediverse {
227 + let domain = scenario.fediverse_domain.as_ref().unwrap();
228 + let webfinger_url = format!(
229 + "/.well-known/webfinger?resource=acct:admin@{}",
230 + domain
231 + );
232 + assert_get_status(&scenario, &webfinger_url, 200);
233 + assert_get_with_accept(
234 + &scenario,
235 + "/actors/admin",
236 + "application/activity+json",
237 + 200,
238 + );
239 + }
240 +
241 + // 9. Cleanup automatique via _guard.drop()
242 + let _ = child.kill();
243 +}
244 +```
245 +
246 +**Helpers à créer dans `tests/common/mod.rs`** :
247 +- `Scenario` : charge le TOML, expose `project_dir`, `port`, `pg_port`, `with_ui`, `with_fediverse`, `with_fediverse_ui`, `fediverse_domain`, `cargo_features()`
248 +- `ScenarioGuard` : RAII pour cleanup garanti (`docker compose down -v`, kill process si vivant, `TempDir` se nettoie via Drop)
249 +- `cli_generate(&scenario) -> Result<()>` : invoke `cargo run -p rustwarden-cli -- start-project --config <fixture>`
250 +- `docker_up(&scenario) -> Result<()>` : `docker compose -p <name> up -d --wait` (préfixe pour éviter conflits)
251 +- `cargo_run(&scenario) -> Result<Child>` : spawn `cargo run --features <X>` en background dans le `project_dir`
252 +- `seed_admin(&scenario) -> Result<()>` : invoke `cargo run --bin seed -- --username admin --email admin@localhost --password Admin12345!` (ou variante selon API du seed actuel)
253 +- `poll_health(&scenario, timeout) -> Result<()>` : boucle GET /health jusqu'à 200 ou timeout
254 +- `login_ui(&scenario, user, pw) -> Result<SessionCookie>` : POST /login form-encoded, suit la 302, capture le cookie de session
255 +- `login_api(&scenario, user, pw) -> Result<JwtToken>` : POST /api/auth/login JSON, parse le JWT
256 +- `assert_get_status(&scenario, path, expected_status)` : GET sans auth, asserte le code
257 +- `assert_get_with_cookie(&scenario, path, &cookie, expected)` : GET avec cookie de session
258 +- `assert_get_with_jwt(&scenario, path, &jwt, expected)` : GET avec `Authorization: Bearer <jwt>`
259 +- `assert_get_with_accept(&scenario, path, accept_header, expected)` : GET avec header `Accept` (pour AP JSON-LD)
260 +
261 +**Gestion du `tempdir`** :
262 +Chaque scénario génère son projet dans `tempfile::TempDir` (ex. `/tmp/rustwarden_test_full_XXXXX/`). La fixture TOML est lue par le runner, ses champs `target_dir`, `port`, `pg_port`, `db_name` sont substitués au runtime (ports uniques + tempdir), puis le runner écrit un TOML résolu temporaire et invoque le CLI avec `--config <tmp.toml>`. La fixture sert de template, le runner produit l'instance résolue.
263 +
264 +**Test négatif** :
265 +```rust
266 +#[test]
267 +#[ignore]
268 +fn test_invalid_fed_ui_without_ui() {
269 + let bad_config = build_bad_config(); // ui=false + fediverse-ui=true
270 + let result = cli_generate_with_raw_config(&bad_config);
271 + assert!(result.is_err());
272 + let stderr = result.unwrap_err();
273 + assert!(stderr.contains("fediverse-ui requires UI"), "wrong error message: {stderr}");
274 +}
275 +```
276 +
277 +### 4. Healthcheck `/health`
278 +
279 +L'endpoint existe déjà via `rustwarden-ui` ([crates/rustwarden-ui/src/handlers/health.rs](crates/rustwarden-ui/src/handlers/health.rs)) → retourne `200` + `{"status": "ok"}` quand UI activée.
280 +
281 +**Cas problématique** : pour les scénarios `test_api_only` et `test_headless_fed` (UI désactivée), `/health` n'existe pas. Il faut soit :
282 +- **A** : Le générer dans le `app_routes()` du `main.rs` template (déjà présent en partie : `/api/hello`). Modifier le template pour exposer aussi `/health` même sans UI.
283 +- **B** : Vérifier le port TCP ouvert au lieu du HTTP (moins fiable, dit juste que le serveur écoute).
284 +
285 +**Décision** : ajouter `/health` au template `app_routes()` du CLI ([templates.rs:316](tools/rustwarden-cli/src/templates.rs#L316)) :
286 +```rust
287 +fn app_routes() -> Router<DatabaseConnection> {
288 + Router::new()
289 + .route("/api/hello", get(hello))
290 + .route("/health", get(|| async { axum::Json(serde_json::json!({"status": "ok"})) }))
291 +}
292 +```
293 +
294 +Cela garantit que **chaque** projet généré expose `/health` même sans UI.
295 +
296 +### 5. Documentation
297 +
298 +- Ajouter une section "Tests E2E" dans le README de `tools/rustwarden-cli/`
299 +- Mentionner la commande : `cargo test -p rustwarden-cli --test e2e_wizard -- --ignored --test-threads=1`
300 +- Note : `--test-threads=1` recommandé pour éviter contention disque sur le build cargo (les 5 scénarios partageraient le `target/` du runner mais build chaque projet généré séparément, ce qui est I/O lourd)
301 +
302 +---
303 +
304 +## Fichiers critiques modifiés / créés (récap)
305 +
306 +| Fichier | Action | Estimation |
307 +|---|---|---|
308 +| `tools/rustwarden-cli/src/templates.rs` | Modif : `#[derive(Serialize, Deserialize)]` sur 5 structs/enums + ajout `/health` dans template `app_routes()` | ~30 lignes |
309 +| `tools/rustwarden-cli/src/start_project.rs` | Modif : refactor `run()` pour accepter `Option<PathBuf>`, extraction de `validate_config()` | ~60 lignes |
310 +| `tools/rustwarden-cli/src/main.rs` | Modif : ajout flag `--config` à clap | ~10 lignes |
311 +| `tools/rustwarden-cli/Cargo.toml` | Modif : ajout `toml = "0.8"`, `tempfile`, `reqwest` (dev-dependencies) | ~5 lignes |
312 +| `tools/rustwarden-cli/tests/e2e_wizard.rs` | Création : test runner Rust avec 5 tests positifs + 1 négatif `#[ignore]` | ~400 lignes |
313 +| `tools/rustwarden-cli/tests/common/mod.rs` | Création : helpers (Scenario, Guard, login_ui, login_api, asserts) | ~300 lignes |
314 +| `tools/rustwarden-cli/tests/fixtures/*.toml` | Création : 5 fixtures TOML | ~25 lignes × 5 = 125 lignes |
315 +| `tools/rustwarden-cli/README.md` | Modif : section tests E2E | ~30 lignes |
316 +
317 +**Total : ~900 lignes ajoutées, ~5-6 heures d'effort développement.**
318 +
319 +---
320 +
321 +## Vérification end-to-end
322 +
323 +Une fois implémenté :
324 +
325 +1. **Test individuel** :
326 + ```bash
327 + cargo test -p rustwarden-cli --test e2e_wizard test_api_only -- --ignored
328 + ```
329 + Exécution attendue ~3-5 min (build cold du projet généré).
330 +
331 +2. **Suite complète** :
332 + ```bash
333 + cargo test -p rustwarden-cli --test e2e_wizard -- --ignored --test-threads=1
334 + ```
335 + Séquentiel pour éviter contention. Estimation : ~15-20 min total (les builds successifs réutilisent les caches cargo).
336 +
337 +3. **CI / pre-commit** : ne PAS exécuter par défaut (trop coûteux). Lancer manuellement avant chaque release ou après modification du CLI / templates / framework critique.
338 +
339 +4. **Validation manuelle** : chaque scénario doit valider TOUTES ses étapes pertinentes (cf. matrice). Si un scénario échoue, l'assertion failed indique précisément où (étape de healthcheck, login, navigation admin, endpoint fediverse, etc.). Les logs `cargo` du projet généré sont conservés dans le `tempdir` jusqu'à la fin du test pour debug post-mortem.
340 +
341 +5. **Critère de succès global** : `cargo test -p rustwarden-cli --test e2e_wizard -- --ignored --test-threads=1` doit retourner exit 0 avec 6/6 tests passés (5 positifs + 1 négatif). Si un seul échoue, le CLI a une régression bloquante.
342 +
343 +---
344 +
345 +## Hors scope (futur potentiel)
346 +
347 +- Tester `update-project` (sync de framework vers projet existant)
348 +- Tests avec SMTP réel (mailhog container ?)
349 +- Tests avec OAuth réel (mock provider ?)
350 +- Tests des routes UI (login form, admin dashboard) — actuellement on vérifie juste que le serveur boot
351 +- CI GitHub Actions / GitLab CI configurée pour lancer ces tests
352 +- Mesure de couverture combinatoire (axes additionnels : auto_migrate, port custom, etc.)
353 +
354 +Ces extensions sont triviales à ajouter une fois le cadre en place : nouveau scénario = nouvelle fixture TOML + un `#[test]` de plus.
355 +
356 +---
357 +
358 +## Risques identifiés
359 +
360 +| Risque | Mitigation |
361 +|---|---|
362 +| Docker absent sur la machine de test | `#[ignore]` par défaut, exécution explicite seulement |
363 +| Conflit de port (5440-5444 / 3010-3014 déjà utilisés) | Ports dynamiques via `0` + lecture du port assigné par PostgreSQL/Axum (refactor optionnel, hors scope initial) |
364 +| Build cargo cold lent (~3 min pour la 1ʳᵉ génération) | Inévitable. Documenter, lancer en CI dédié pas en pre-commit |
365 +| Cleanup échoue (process orphelin) | `ScenarioGuard::drop()` + `kill -9` agressif au teardown |
366 +| Tempdir non nettoyé en cas de panic | `tempfile::TempDir` gère `Drop` même en panic |
367 +| Conflit de nom de container Docker | Préfixer par scénario : `compose -p test_api_only_<rand>` |
A static/js/fediverse-sse.js
+66 -0
@@ -0,0 +1,66 @@
1 +// RFC 0023 Slice B iter. 3 — Server-Sent Events listener for the
2 +// fediverse login flow. Loaded from the `login_waiting.html` template.
3 +//
4 +// ANSSI FUI-CSP-1: previously an inline <script>, externalised so the
5 +// global Content-Security-Policy can drop `script-src 'unsafe-inline'`
6 +// for this template. The challenge_id is passed via a `data-*`
7 +// attribute on the script tag itself.
8 +(function () {
9 + var script = document.getElementById("fediverse-sse-script");
10 + if (!script) {
11 + return;
12 + }
13 + var challengeId = script.getAttribute("data-challenge-id");
14 + if (!challengeId) {
15 + return;
16 + }
17 + var sse = new EventSource("/login/fediverse/sse/" + challengeId);
18 +
19 + sse.addEventListener("status", function (ev) {
20 + var payload;
21 + try {
22 + payload = JSON.parse(ev.data);
23 + } catch (_err) {
24 + return;
25 + }
26 + var state = payload.state;
27 + if (state === "approved") {
28 + // L'IdP a approuvé : appeler /api/v1/auth/login/fediverse/consume/{id}
29 + // qui pose le cookie JWT remote, puis redirection /home.
30 + fetch("/api/v1/auth/login/fediverse/consume/" + challengeId, {
31 + method: "POST",
32 + credentials: "same-origin",
33 + })
34 + .then(function (r) {
35 + if (r.ok) {
36 + window.location.href = "/home";
37 + } else {
38 + window.location.href = "/login/fediverse/error?reason=unknown";
39 + }
40 + })
41 + .catch(function () {
42 + window.location.href = "/login/fediverse/error?reason=unknown";
43 + });
44 + sse.close();
45 + } else if (state === "rejected") {
46 + sse.close();
47 + window.location.href = "/login/fediverse/error?reason=rejected";
48 + } else if (state === "expired") {
49 + sse.close();
50 + window.location.href = "/login/fediverse/error?reason=timeout";
51 + } else if (state === "consumed") {
52 + sse.close();
53 + window.location.href = "/home";
54 + }
55 + // state === "pending" → rien à faire, on attend le prochain poll.
56 + });
57 +
58 + sse.onerror = function () {
59 + // Connexion perdue ; le serveur va relancer mais on protège
60 + // l'UX si le navigateur abandonne après plusieurs tentatives.
61 + sse.close();
62 + setTimeout(function () {
63 + window.location.href = "/login/fediverse/error?reason=timeout";
64 + }, 30000);
65 + };
66 +})();
M tools/rustwarden-cli/src/start_project.rs
+23 -8
@@ -25,8 +25,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
25 25 let name = ask_project_name()?;
26 26 let app_display_name = ask_app_display_name(&name)?;
27 27 let description = ask_description(&name)?;
28 - let target_dir = ask_target_dir()?;
29 - let project_dir = target_dir.join(&name);
28 + let project_dir = ask_target_dir(&name)?;
30 29
31 30 handle_existing_dir(&project_dir)?;
32 31
@@ -120,13 +119,19 @@ fn ask_project_name() -> Result<String, Box<dyn std::error::Error>> {
120 119 }
121 120 if !input
122 121 .chars()
123 - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
122 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
124 123 {
125 124 return Err(
126 - "Seuls les caracteres alphanumeriques, tirets et underscores sont autorises"
125 + "Seuls les caracteres alphanumeriques minuscules, tirets et underscores sont autorises (Docker Compose exige lowercase)"
127 126 .into(),
128 127 );
129 128 }
129 + let first = input.chars().next().unwrap_or(' ');
130 + if !first.is_ascii_alphanumeric() {
131 + return Err(
132 + "Le nom doit commencer par une lettre ou un chiffre (Docker Compose)".into(),
133 + );
134 + }
130 135 Ok(())
131 136 })
132 137 .interact_text()?;
@@ -152,13 +157,14 @@ fn ask_description(name: &str) -> Result<String, Box<dyn std::error::Error>> {
152 157 Ok(desc)
153 158 }
154 159
155 -fn ask_target_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
160 +fn ask_target_dir(name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
161 + let default = format!("./{name}");
156 162 let dir: String = Input::new()
157 163 .with_prompt(format!(
158 164 "{}",
159 - style("Dossier cible (le projet sera cree dans ce dossier)").cyan()
165 + style("Dossier cible (le projet sera cree a cet emplacement)").cyan()
160 166 ))
161 - .default(".".into())
167 + .default(default)
162 168 .interact_text()?;
163 169
164 170 let target = PathBuf::from(&dir);
@@ -444,7 +450,16 @@ fn ask_smtp_config(
444 450 // ---------------------------------------------------------------------------
445 451
446 452 fn generate_random_password(len: usize) -> String {
447 - const CHARSET: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%&*";
453 + // Strictly safe charset for passwords that flow through three layers:
454 + // 1. DATABASE_URL — RFC 3986 reserved chars (@, #, %, &, /, ?, :)
455 + // would break the postgres://user:pass@host/db parser.
456 + // 2. .env files consumed by Docker Compose — `$` triggers variable
457 + // substitution (`$VAR`); `~` may expand to `$HOME` in some shells.
458 + // 3. Interactive shells — `!` triggers history expansion in bash,
459 + // `*` is a glob, `&`/`|`/`;` are job-control or pipe operators.
460 + // We keep alphanumeric + `-_` (RFC 3986 unreserved minus `.~`) which
461 + // are inert in all three contexts.
462 + const CHARSET: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789-_";
448 463 let mut rng = rand::rng();
449 464 (0..len)
450 465 .map(|_| {
M tools/rustwarden-cli/src/templates.rs
+9 -1
@@ -162,9 +162,16 @@ pub fn cargo_toml(cfg: &ProjectConfig) -> String {
162 162 members = ["crates/rustwarden-core"{ui_member}{fediverse_member}{fediverse_ui_member}]
163 163 resolver = "2"
164 164
165 +[workspace.package]
166 +edition = "2024"
167 +rust-version = "1.85"
168 +
165 169 [workspace.lints.clippy]
166 170 empty_line_after_doc_comments = "allow"
167 171
172 +[workspace.lints.rust]
173 +missing_docs = "warn"
174 +
168 175 [package]
169 176 name = "{name}"
170 177 version = "0.1.0"
@@ -250,7 +257,8 @@ pub fn main_rs(cfg: &ProjectConfig) -> String {
250 257
251 258 (
252 259 "let builder = RustwardenApp::builder()\n",
253 - r#"
260 + r#";
261 +
254 262 #[cfg(feature = "fediverse")]
255 263 let builder = {
256 264 use std::sync::Arc;