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 | |
| 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; |
GitRust