07 — Modèle de sécurité
Ce document décrit les contrôles de sécurité mis en œuvre dans Rustwarden : comment les tokens sont forgés et révoqués, comment le CSRF est bloqué, comment les mots de passe et les secrets OAuth sont protégés, et où se trouvent les limites connues. L'objectif est de donner à un auditeur ou à un intégrateur une vue complète et honnête — y compris des points d'attention explicites.
Vue d'ensemble des contrôles
| Vecteur d'attaque | Contrôle mitigant | Fichier |
|---|---|---|
| Usurpation d'identité (token forgé) | Signature HMAC-SHA256, validation issuer + audience | auth/jwt.rs |
| Replay de JWT révoqué | Blacklist jwt_blacklist consultée à chaque requête | services/jwt_blacklist_service.rs |
| Vol de password hash | bcrypt coût 12 (min), résistant aux GPU | services/password_service.rs |
| Exposition de secret OAuth/TOTP en DB | Chiffrement AES-256-GCM avant insertion | services/crypto_service.rs |
| CSRF (Cross-Site Request Forgery) | Token de session 256 bits, comparaison constant-time | middleware/csrf.rs |
| Brute-force login / reset password | Rate limiter in-process, fenêtre glissante | services/rate_limit_service.rs |
| Timing oracle sur comparaisons | subtle::ConstantTimeEq partout | middleware/csrf.rs, services/personal_token_service.rs |
| XSS via injection de contenu | CSP appliquée sur toutes les réponses | app.rs |
| Clickjacking | frame-ancestors 'none' dans CSP + X-Frame-Options | app.rs |
| Open redirect | Validation du paramètre redirect (doit commencer par /) | middleware auth_required |
| Fuite de session via TOTP brute-force | Max 5 tentatives par challenge, 5 min TTL | services/totp_service.rs |
| Rejeu d'un code TOTP valide (multi-réplicas) | user_totp.last_used_step consommé par compare-and-set SQL (RFC 0037) | services/totp_service.rs |
| Injection via username (chemins d'URL, logs, templates) | Newtype Username sur les frontières d'écriture (RFC 0036) | types/username.rs |
| Prise de contrôle par changement d'e-mail | Flux en deux temps : mot de passe courant exigé, adresse écrite seulement après clic sur le lien envoyé à la nouvelle boîte, alerte + lien de signalement à l'ancienne, sessions révoquées à la confirmation (RFC 0041) | services/email_change_service.rs |
| Confirmation déclenchée par le préchargement d'un lien (passerelle anti-phishing) | Page interstitielle : aucun GET n'a d'effet de bord, l'action passe par le POST d'un formulaire (RFC 0041) | auth/email_change_pages.rs |
| Contournement de la politique « e-mail vérifié » par appel direct au service | Contrôle déplacé dans AuthService::authenticate_user (RFC 0040) | services/auth_service.rs |
| Élévation de privilèges via lien OAuth | Vérification email vérifié par le provider | services/oauth/service.rs |
| Déni de service mémoire sur rate limiter | Cap à 100 000 entrées distinctes | services/rate_limit_service.rs |
Cryptographie utilisée
JWT — HS256
Les tokens JWT sont signés avec l'algorithme HS256 (HMAC-SHA256) via la crate jsonwebtoken 10. La clé est lue depuis la variable d'environnement JWT_SECRET. AuthConfig rejette au démarrage les valeurs trop courtes ou les placeholders connus (changeme, secret, etc.).
Chaque token porte un jti (JWT ID) unique (UUID v4) qui permet la révocation individuelle sans invalider les autres tokens de l'utilisateur.
La validation exige :
- la signature HMAC correcte,
expnon dépassé,issetaudcorrespondant àAuthConfig::jwt_issuer.
bcrypt pour les mots de passe
Les mots de passe sont hachés avec bcrypt. Le coût par défaut est 12, configurable via la variable BCRYPT_COST (clampé entre 12 et 20 pour éviter des valeurs non sécurisées ou bloquantes). Le hachage est réalisé sur un thread bloquant dédié (tokio::task::spawn_blocking) pour ne pas bloquer le runtime async.
coût effectif = BCRYPT_COST.clamp(12, 20) (défaut : 12) durée typique à coût 12 : ~100–300 ms
AES-256-GCM pour les secrets en base
Les secrets sensibles stockés en base (secrets TOTP, codes de backup TOTP) sont chiffrés avec AES-256-GCM via la crate aes-gcm. La clé est lue depuis ENCRYPTION_KEY (ou OAUTH_ENCRYPTION_KEY — alias legacy), encodée en hexadécimal (64 caractères = 32 octets).
Format de stockage : enc:<base64(nonce || ciphertext)>. Le préfixe enc: permet de distinguer les valeurs chiffrées des valeurs legacy en clair (compatibilité migration).
Le nonce (12 octets) est généré aléatoirement par OsRng pour chaque chiffrement. Un nonce différent par opération garantit la sécurité sémantique.
Référence : services/crypto_service.rs
SHA-256 pour les personal access tokens
Le plaintext d'un PAT (32 octets aléatoires encodés hex, soit 64 caractères) est haché avec SHA-256 avant stockage. Seul le digest hex est persisté. Le plaintext est retourné une seule fois lors de la création et n'est jamais journalisé.
Comparaisons constant-time
Partout où une comparaison de token peut exposer un timing oracle, Rustwarden utilise subtle::ConstantTimeEq :
- Validation du token CSRF (header vs session)
- Re-vérification du hash PAT après lookup SQL
- Validation du token de réinitialisation de mot de passe
JWT — anatomie
flowchart LR
subgraph Header
ALG["alg: HS256"]
TYP["typ: JWT"]
end
subgraph Payload
SUB["sub: UUID utilisateur"]
JTI["jti: UUID unique du token"]
ISS["iss: jwt_issuer"]
AUD["aud: jwt_issuer"]
EXP["exp: timestamp expiration"]
IAT["iat: timestamp émission"]
USERNAME["username: string"]
ROLES["roles: string[]"]
PERMS["permissions: string[]"]
KIND["kind: local | remote"]
RID["remote_id: UUID? (RFC 0007)"]
end
subgraph Signature
HMAC["HMAC-SHA256(header.payload, JWT_SECRET)"]
end
Header --> Payload --> Signature
Les claims kind et remote_id ont été introduits par RFC 0007 pour permettre aux middlewares de distinguer un compte local d'une identité fédérée (remote_identities.id) sans requête en base. Les tokens antérieurs à RFC 0007 désérialisent kind comme local par défaut.
Important : les claims ne sont pas chiffrés, uniquement signés. Ne jamais y placer de données sensibles (mot de passe, secret).
JWT — états et transitions
stateDiagram-v2
[*] --> Generated : JwtService::generate_token()
Generated --> Active : Émis au client
Active --> Expired : exp atteint
Active --> Blacklisted : logout / révocation admin\nJwtBlacklistService::blacklist()
Active --> Revoked : refresh token réutilisé\n(détection de vol)
Expired --> [*] : rejeté par validate_token() (ExpiredSignature)
Blacklisted --> [*] : rejeté par JwtBlacklistService::is_blacklisted()
Revoked --> [*] : rejeté (blacklisté + tous les tokens de la chaîne révoqués)
La blacklist est nettoyée en arrière-plan : les entrées dont expires_at < NOW() sont supprimées car elles ne peuvent plus être présentées avec une signature valide.
CSRF
Le middleware CSRF protège toutes les routes non-GET/HEAD/OPTIONS contre les requêtes forgées depuis un autre domaine.
sequenceDiagram
participant B as Navigateur
participant S as Rustwarden
B->>S: GET /form
S->>S: get_or_create_csrf_token(session)
Note right of S: Génère 32 octets aléatoires\nencodés hex (64 chars)
S->>B: 200 OK<br/>cookie: session=...<br/>HTML: <input name="csrf_token" value="..."><br/>ou header X-CSRF-Token injecté par JS
B->>S: POST /submit<br/>X-CSRF-Token: <token> (ou form field)
S->>S: Extraire token depuis header ou form body
S->>S: csrf_tokens_match(incoming, session_token)<br/>subtle::ConstantTimeEq
alt Token valide
S->>B: 200 OK
else Token absent ou incorrect
S->>B: 403 Forbidden
end
Bypass pour les API SPA : si la requête porte un header Authorization: Bearer ..., le middleware CSRF est court-circuité. Un client API qui transporte son JWT manuellement n'est pas vulnérable au CSRF car le navigateur ne peut pas ajouter ce header automatiquement depuis un autre domaine.
Deux modes :
csrf_protection(défaut) : token uniquement dans le headerX-CSRF-Token.csrf_protection_with_configavecaccept_form_body = true: accepte aussi le champcsrf_tokendans un corpsapplication/x-www-form-urlencoded, utile pour les formulaires SSR sans JavaScript. Les corps multipart ne sont jamais bufférisés (risque d'uploads volumineux).
Référence : middleware/csrf.rs
Rate limiting
flowchart TD
REQ[Requête entrante] --> EXTRACT[Extraire client_id\nIP ou user_id]
EXTRACT --> LOOKUP[Lookup dans HashMap en mémoire\nMutex<HashMap<String, WindowCounter>>]
LOOKUP --> WINDOW{Fenêtre expirée ?}
WINDOW -->|Oui| RESET[Réinitialiser compteur\nwindow_start = now]
WINDOW -->|Non| CHECK
RESET --> CHECK[count += 1\ncount > max ?]
CHECK -->|Non| OK[Passer la requête]
CHECK -->|Oui| REJECT[429 Too Many Requests\nRetry-After: N secondes]
LOOKUP --> CAPCHECK{Taille map >= 100 000 ?}
CAPCHECK -->|Oui et clé inconnue| REJECT
Deux modes disponibles :
- Fenêtre fixe (
check/check_window) : compteur remis à zéro à chaque fin de fenêtre. Simple et peu coûteux. - Fenêtre glissante (
check_sliding_window) : timestamp de chaque tentative stocké, rejet si plus de N tentatives dans les derniers W secondes. Plus strict, utilisé pour les flows sensibles (reset de mot de passe, envoi de code TOTP).
Limitation connue : le rate limiter est in-process uniquement. Dans un déploiement multi-réplicas, chaque instance maintient son propre compteur. Un attaquant qui répartit ses requêtes entre N réplicas peut multiplier son quota par N. La solution prévue en V2 est un backend partagé (Redis). Cette limitation est documentée dans le code source lui-même.
Référence : services/rate_limit_service.rs
Complexité des mots de passe
La politique de mot de passe est appliquée lors de la création et du changement de mot de passe via le service de validation. Le middleware password_complexity vérifie :
- Longueur minimale configurable (défaut : 8 caractères)
- Au moins une majuscule, une minuscule, un chiffre (selon configuration)
- Pas de correspondance avec les placeholders connus
Les administrateurs peuvent forcer un changement de mot de passe via le flag must_change_password sur la table users. Le middleware auth_required détecte ce flag et redirige vers le formulaire de changement avant de laisser passer toute autre requête.
Politique de nommage des comptes (Username)
Depuis la RFC 0036, un username n'est plus une String libre bornée en longueur : c'est un newtype validé, rustwarden_core::types::Username, re-exporté par le prelude. Il est calqué sur TeamSlug et suit les règles de GitHub.
Règles appliquées par Username::parse
| Règle | Détail |
|---|---|
| Longueur | 3 à 39 caractères, mesurés après trim() |
| Charset | [a-z0-9-] uniquement (minuscules ASCII, chiffres, tiret) |
| Majuscules | Rejetées, jamais repliées : Alice renvoie une erreur, il ne devient pas alice |
| Tirets | Ni en tête ni en queue, pas de -- consécutifs |
| Noms réservés | types::team_slug::RESERVED + EXTRA_RESERVED (me, explore) |
Le rejet des majuscules plutôt qu'un to_lowercase() silencieux est un choix de sécurité : un fold implicite permettrait à Alice et alice d'entrer en collision sur la même ligne, ou de créer un compte visuellement différent d'un compte existant. Toute violation renvoie AppError::Validation → HTTP 400.
Où la règle est appliquée
Une seule porte de validation — UserService::create_user — plus un contrôle de frontière publique sur POST /api/v1/auth/register (défense en profondeur, RegisterRequest.username est borné à 39 côté validator).
flowchart TD
REG["POST /api/v1/auth/register"] --> PARSE1["Username::parse()"]
ADMIN["Handler admin / provisioning"] --> CREATE
PARSE1 --> CREATE["UserService::create_user()"]
CREATE --> PARSE2["Username::parse() — porte unique"]
PARSE2 --> INSERT[("INSERT users")]
BOOT["ensure_default_admin()"] --> ALLOW["create_user_allow_reserved()\nUsername::parse_allow_reserved()"]
ALLOW --> INSERT
PROFILE["update_profile(username = Some(x))"] --> SAME{"x == username courant ?"}
SAME -->|Oui| NOOP["No-op — grandfathering préservé"]
SAME -->|Non| PARSE3["Username::parse() + contrôle d'unicité"]
PARSE3 --> UPDATE[("UPDATE users")]
create_user_allow_reserved est pub(crate) et réservé à ensure_default_admin : il accepte les noms réservés (pour que ADMIN_USERNAME=admin continue de booter) mais applique toujours le charset et la longueur. AdminConfig::validate délègue au même parse permissif — un ADMIN_USERNAME avec une majuscule ou un _ fait échouer le boot sur le chemin de création de l'admin.
Grandfathering
Aucun CHECK SQL n'a été ajouté, aucune migration ne réécrit les lignes existantes. Conséquences :
- un compte historique hors charset (
John_Doe,alice.bob) se connecte toujours — les chemins de lecture n'appellent jamaisparse; update_profilene valide que sur un vrai rename. Si le client renvoie le username courant (les formulaires de profil postent tous les champs), c'est un no-op complet ;- ce même compte ne peut pas être renommé vers une autre valeur hors charset.
Utiliser Username dans votre propre code
Utile dès que vous générez ou acceptez un identifiant avant de le confier au framework (import LDAP, provisioning SSO, seed, CLI d'administration) :
use rustwarden_core::prelude::*; // Username, UserService, AppError async fn provision( db: &sea_orm::DatabaseConnection, raw_login: &str, email: String, password_hash: String, ) -> Result<(), AppError> { // Échoue tôt, avec un message exploitable, avant tout accès base. let username = Username::parse(raw_login)?; UserService::create_user(db, username.into_inner(), email, password_hash, true).await?; Ok(()) }
Pour n'afficher qu'un message de validation côté formulaire :
if let Err(e) = Username::parse(&form.username) { return Ok(render_form_with_error(&e.to_string())); }
Les candidats générés automatiquement par OAuth passent par OAuthService::generate_unique_username, qui produit déjà des valeurs conformes : minuscules [a-z0-9], base tronquée à 30 caractères (marge pour les suffixes -NNN et -<hex8>), suffixe -user si la base tombe sur un nom réservé.
TOTP / 2FA
sequenceDiagram
participant U as Utilisateur
participant API as Rustwarden
participant DB as Base de données
Note over U,DB: Enrôlement TOTP
U->>API: POST /api/v1/totp/setup
API->>API: Secret::generate_secret() (totp-rs)
API->>API: CryptoService::encrypt(secret_base32)
API->>DB: INSERT user_totp (enabled=false, secret_encrypted=...)
API->>U: otpauth:// URI + QR code base64
U->>API: POST /api/v1/totp/verify-and-enable\n{ code: "123456" }
API->>DB: SELECT user_totp WHERE user_id = ...
API->>API: CryptoService::decrypt(secret_encrypted)
API->>API: TOTP::check_current(code)
API->>API: Générer 10 codes de backup (bcrypt hashés, AES-GCM chiffrés)
API->>DB: UPDATE user_totp SET enabled=true, backup_codes_encrypted=...
API->>U: 200 OK + codes de backup (affichage unique)
Note over U,DB: Login avec 2FA
U->>API: POST /api/v1/auth/login { username, password }
API->>API: Vérifier password (bcrypt)
API->>DB: SELECT user_totp WHERE user_id AND enabled=true
alt TOTP activé
API->>DB: INSERT totp_challenges (token, expires_at=+5min, max_attempts=5)
API->>U: 200 { totp_required: true, challenge_token: "..." }
U->>API: POST /api/v1/auth/totp { challenge_token, code }
API->>DB: SELECT totp_challenges WHERE challenge_token=...
API->>API: Vérifier expiration + tentatives
API->>API: TOTP::check_current(code)
API->>DB: DELETE totp_challenges (consommer le challenge)
API->>U: JWT + refresh token
else TOTP non activé
API->>U: JWT + refresh token
end
L'algorithme TOTP utilisé est SHA1, 6 chiffres, période 30 secondes — conformément à la spécification RFC 6238 et à la compatibilité avec tous les authenticators standard (Google Authenticator, Authy, etc.).
Référence : services/totp_service.rs
Plafonds de tentatives (1.0.1)
| Contrôle | Valeur | Constante |
|---|---|---|
| Tentatives par challenge | 5 | MAX_CHALLENGE_ATTEMPTS |
| Somme des tentatives par utilisateur / 15 min | 15 | MAX_ATTEMPTS_PER_WINDOW |
| TTL d'un challenge | 5 min | CHALLENGE_EXPIRY_MINUTES |
| TTL de la garde anti-rejeu in-process | 90 s | REPLAY_GUARD_TTL_SECS |
Le plafond global est vérifié à la création du challenge (TotpService::create_challenge) et compte la somme des tentatives sur tous les challenges de la fenêtre, et non le nombre de challenges saturés : créer un nouveau challenge ne remet pas le budget à zéro. Un dépassement renvoie AppError::TotpMaxAttemptsExceeded.
Anti-rejeu persistant — last_used_step (RFC 0037)
Un code TOTP reste valide pendant toute sa fenêtre de 30 secondes, plus le skew ±1 accepté par totp-rs, soit ~90 secondes d'exploitabilité. En 1.0.1, la protection contre le rejeu était une HashMap in-process (OnceLock) : elle ne couvrait qu'un seul processus. Dans un déploiement multi-réplicas, un code intercepté et rejoué sur un autre réplica passait.
La RFC 0037 déplace l'autorité en base. La migration m20260918_000010_totp_last_used_step ajoute user_totp.last_used_step BIGINT NULL, et la consommation du pas se fait par compare-and-set :
sequenceDiagram
participant R1 as Réplica 1
participant R2 as Réplica 2
participant DB as user_totp
Note over R1,DB: Code "123456" valide pour le pas t
R1->>R1: matching_totp_step(secret, code) → Some(t)
R1->>DB: UPDATE ... SET last_used_step = t<br/>WHERE user_id = $id AND (last_used_step IS NULL OR last_used_step < t)
DB-->>R1: rows_affected = 1 → Ok(true)
Note over R2,DB: Rejeu du même code sur un autre réplica
R2->>R2: matching_totp_step(secret, code) → Some(t)
R2->>DB: même UPDATE conditionnel
DB-->>R2: rows_affected = 0 → Ok(false)
Points importants :
- le pas persisté est celui qui a réellement matché (
matching_totp_step, qui rejouegenerate()surt-1,t,t+1après unTOTP::checkconstant-time), jamaisnow / 30— sinon un code accepté au skew-1n'aurait pas été consommé ; - la condition
last_used_step IS NULL OR last_used_step < $stepest strictement croissante : le même pas ne peut pas être consommé deux fois ; - un rejeu renvoie
Ok(false), exactement comme un code invalide. Pas d'oracle de rejeu : l'attaquant ne peut pas distinguer « déjà utilisé » de « faux » ; verify_and_enable(le code d'activation du 2FA) consomme lui aussi le pas, avant de basculerenabled = true: le code de setup ne peut pas servir de login immédiat sur un autre réplica ;- une erreur base remonte en
AppError— le garde ne fail jamais open ; - la garde in-process de 1.0.1 est conservée après le CAS, en défense en profondeur ;
last_used_step IS NULLsignifie « aucun pas consommé depuis la migration » : le premier code présenté après migration est toujours accepté (grandfathering des lignes existantes).
Ce que l'anti-rejeu ne garantit PAS
- Il ne protège pas contre un MITM temps réel. Un proxy de phishing type Evilginx intercepte le code et le rejoue lui-même avant l'utilisateur légitime : c'est la première consommation du pas, elle gagne le CAS. Le TOTP n'est pas phishing-resistant par construction ; seuls WebAuthn/FIDO2 le sont, et ils sont hors périmètre de Rustwarden aujourd'hui.
- Il ne protège pas contre un secret TOTP compromis. Qui détient le secret génère des codes pour les pas suivants.
- Il ne protège pas le vol de session post-authentification. Une fois le JWT émis, le TOTP n'intervient plus — voir blacklist JWT et rotation des refresh tokens.
- Il ne couvre pas les codes de backup, qui suivent leur propre cycle (consommés par suppression du hash bcrypt de la liste chiffrée).
Cycle de vie de l'e-mail vérifié
Le flag users.email_verified (et son horodatage email_verified_at) conditionne l'entrée dans le compte — c'est-à-dire l'authentification, pas les sessions déjà ouvertes. Trois moments structurent son cycle de vie.
stateDiagram-v2
[*] --> NonVerifie : register (email_verified = false)
NonVerifie --> Verifie : clic sur le lien<br/>GET /api/v1/auth/verify-email/:token
Verifie --> Verifie : changement d'adresse confirmé<br/>EmailChangeService::confirm_change (RFC 0041)<br/>nouvelle adresse écrite DÉJÀ vérifiée
NonVerifie --> NonVerifie : POST /api/v1/auth/resend-verification<br/>(invalide les tokens précédents)
note right of NonVerifie
Prochain login refusé si
validation_email_required = true
(défaut, fail-closed).
Session en cours NON révoquée.
end note
note right of Verifie
RFC 0041 : un compte vérifié ne
redevient jamais non vérifié.
Tant que le lien n'est pas suivi,
rien ne change en base.
La confirmation révoque en revanche
TOUS les refresh tokens.
end note
1. Inscription
UserService::create_user insère toujours email_verified = false. L'exception est le bootstrap : ensure_default_admin force email_verified = true sur l'administrateur par défaut, sinon le premier compte ne pourrait pas se connecter.
2. Changement d'adresse (RFC 0041)
Depuis la RFC 0041, UserService::update_profile n'écrit plus jamais users.email : un vrai changement renvoie une AppError::Validation qui renvoie vers EmailChangeService, un echo de l'adresse courante reste un no-op. Le changement réel est un flux en deux temps, et c'est la clé du modèle de menace : tant que le lien envoyé à la nouvelle boîte n'est pas suivi, rien ne change en base.
| Étape | Comportement |
|---|---|
| 1. Demande | EmailChangeService::request_change re-authentifie avec le mot de passe courant — une session volée seule ne suffit pas à ouvrir une prise de contrôle. Valide format et unicité, consomme toute demande pendante du compte, écrit une ligne pending_email_changes (token SHA-256, expires_at = email_validation_expiration_hours). users.email et email_verified ne bougent pas |
| 1bis. Mails | Deux envois : lien de confirmation vers la nouvelle adresse, avis + lien de signalement vers l'ancienne. Pas best-effort : si la mise en file échoue, request_change échoue et rien n'est écrit |
| 2. Confirmation | Le POST de la page interstitielle écrit la nouvelle adresse déjà vérifiée (email_verified = true), révoque tous les refresh tokens du compte, invalide les demandes de changement de mot de passe pendantes, trace audit_logs email_changed |
| 2bis. Signalement | Le POST de la page de signalement marque la demande, trace en audit_logs (WARN) et alerte tous les administrateurs. Il ne bloque pas le changement |
| Limiteur | 3 demandes / heure / utilisateur (email_change:{user_id}, budget distinct) + budget IP sur POST /api/v1/auth/email-change |
| Logs | Adresses masquées (mask_email → jo***@ex***) dans les logs, l'audit, les hooks et le corps de réponse — aucune adresse complète en clair dans le trail SIEM |
Le chemin admin n'est plus un effet de bord : PUT /admin/users/:id ne change plus l'adresse. Le recours opérateur est EmailChangeService::admin_force_change, qui exige le rôle admin, révoque les sessions de la cible, notifie les deux boîtes et trace email_changed_by_admin (WARN) + on_audit_event. Le hook on_email_verified n'est pas émis : il n'y a eu aucune preuve de possession.
L'avis vers l'ancienne adresse reste le signal d'alarme du scénario « session volée, l'attaquant change l'e-mail pour verrouiller le compte » — et il porte désormais un lien d'action, pas seulement une information.
Pourquoi le signalement n'annule pas le changement
Le lien envoyé à l'ancienne adresse alerte, il ne décide pas. C'est délibéré : donner à cette boîte un pouvoir de veto reviendrait à donner ce veto à un attaquant qui la contrôle déjà, et à bloquer la reprise de contrôle légitime d'un utilisateur qui change d'adresse précisément parce qu'il a perdu l'accès à l'ancienne. Le signalement produit une ligne audit_logs WARN et un mail à tous les administrateurs : la décision de suspendre le compte est humaine et tracée.
Pourquoi un GET ne confirme jamais rien
Les deux liens envoyés par mail pointent sur un GET qui rend une page portant un formulaire ; c'est le POST du bouton qui agit. Les passerelles anti-phishing et les clients mail préchargent les liens : un GET qui agirait laisserait un scanner réécrire silencieusement l'identité de connexion d'un compte, ou réveiller tous les administrateurs pour rien. Ces pages sont du HTML minimal autoportant produit par le core — ni Askama, ni rustwarden-ui, ni JavaScript, ni ressource externe — donc montables en headless, et servies sur le canal machine (jamais derrière la couche CSRF, sans quoi elles seraient inutilisables depuis une boîte mail).
Ce que le flux en deux temps ferme
| Mode de panne | Statut |
|---|---|
| Faute de frappe = lock-out | Fermé. L'adresse candidate n'est jamais écrite : le lien part dans le vide, la demande expire, le compte n'a jamais bougé et reste vérifié sur son adresse d'origine |
| Fenêtre « adresse changée, session toujours valide » | Fermée. La confirmation révoque tous les refresh tokens du compte |
Couplage avec validation_email_required | Supprimé. La nouvelle adresse est écrite déjà vérifiée : un changement d'adresse ne bloque jamais le login suivant |
| Changement d'adresse par effet de bord d'une mise à jour de profil | Fermé. Un seul chemin utilisateur, un seul chemin opérateur, tous deux audités |
Risque résiduel : recours perdu et faute de frappe à l'inscription
| Recours perdu | Un utilisateur qui a perdu à la fois son mot de passe et l'accès à son adresse courante ne peut plus se débloquer seul : request_change exige le mot de passe, et le reset de mot de passe passe par l'adresse stockée. C'est un recours administrateur (admin_force_change) après vérification d'identité hors bande. |
| Faute de frappe à l'inscription | Hors périmètre de la RFC 0041 : register écrit l'adresse saisie sans double vérification, et un compte jamais vérifié reste bloqué au login si validation_email_required = true. Même recours administrateur. |
| Recommandation consommateur | Ajouter une double saisie dans l'écran d'inscription (le seul où elle a encore une valeur), et documenter la procédure de vérification d'identité hors bande avant tout admin_force_change. |
3. Où vit la politique de login (RFC 0040)
Le contrôle « login refusé si l'e-mail n'est pas vérifié » vit dans AuthService::authenticate_user, pas dans le handler HTTP. C'est le changement de la RFC 0040 : tant que le contrôle vivait dans POST /api/v1/auth/login, une application consommatrice avec son propre SSR pouvait appeler le service et contourner la politique sans s'en rendre compte.
| Fonction | Contrôle e-mail | Usage attendu |
|---|---|---|
AuthService::authenticate_user | Oui | Porte d'authentification complète — à utiliser par défaut |
AuthService::verify_credentials | Non | Primitive « mot de passe seul » : re-prompt avant setup TOTP, ré-authentification sensible |
Détails du contrôle :
- réglage
validation_email_required, alias legacyvalidation_email_requise, lu viaAppSettingsService::get_bool_with_fallback; - défaut
trueet fail-closed : une erreur de lookup en base vaut « vérification requise » (unwrap_or(true)) ; - il s'exécute après la vérification du mot de passe : un attaquant ne peut pas utiliser le message d'erreur comme oracle d'existence de compte ;
- le rejet est un
AppError::Validation(HTTP 400) portant un message français destiné à l'utilisateur final.
use rustwarden_core::services::auth_service::AuthService; use rustwarden_core::error::AppError; // SSR maison : la politique s'applique, sans code supplémentaire. match AuthService::authenticate_user(&db, &form.username, &form.password).await { Ok(user) => issue_session(user).await, // Message déjà rédigé pour l'utilisateur ; proposer « renvoyer l'email ». Err(AppError::Validation(msg)) => render_login_with_error(&msg, /* show_resend = */ true), Err(AppError::InvalidCredentials) => render_login_with_error("Identifiants invalides.", false), Err(e) => Err(e), }
Limite documentée (décision v1) : sur ce chemin, AuthLoggingService::log_blocked_login est appelé avec hooks = None. La ligne audit_logs (action = "login_blocked") est toujours écrite, mais le hook RustwardenHooks::on_audit_event / auth.login_blocked n'est plus émis. Une alerte branchée sur ce hook doit basculer sur la table audit_logs.
En-têtes de sécurité (CSP, COOP, CORP)
Le middleware security_headers_middleware (app.rs) pose les en-têtes de sécurité sur toutes les réponses. Depuis la RFC 0039, une partie est configurable via SecurityHeadersConfig.
| En-tête | Configurable | Valeur par défaut |
|---|---|---|
Content-Security-Policy | Oui | voir ci-dessous |
Cross-Origin-Opener-Policy | Oui, opt-in | non émis (None) |
Cross-Origin-Resource-Policy | Oui, opt-in | non émis (None) |
X-Content-Type-Options | Non | nosniff |
X-Frame-Options | Non | DENY |
X-XSS-Protection | Non | 0 (auditeur legacy désactivé — il introduisait ses propres failles) |
Referrer-Policy | Non | strict-origin-when-cross-origin |
Permissions-Policy | Non | camera=(), microphone=(), geolocation=() |
Strict-Transport-Security | Non | max-age=31536000; includeSubDomains, sauf si APP_DEBUG=true |
CSP par défaut
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'
Point d'attention, toujours d'actualité : la politique par défaut autorise 'unsafe-inline' et 'unsafe-eval'. 'unsafe-eval' est requis par la build standard d'Alpine.js (qui évalue ses expressions x-*), 'unsafe-inline' par HTMX et les scripts inline des templates. Ces directives affaiblissent significativement la protection XSS.
La RFC 0039 n'a pas durci ce défaut : elle n'ajoute qu'une API. Le défaut de 1.1.0 est bit-identique à celui de 1.0.1 — un test de non-dérive (default_csp_matches_the_historical_literal) le verrouille. La constante CSP_WITHOUT_UNSAFE_EVAL est exposée mais non utilisée par Default : la bascule est une Phase 2, conditionnée à la migration vers la build Alpine CSP (plans/PLAN_CSP_HTMX.md).
Autrement dit : un consommateur qui n'appelle jamais .security_headers(..) n'obtient aucune amélioration automatique de sa CSP — et aucune régression non plus.
Configurer les en-têtes
use rustwarden_core::prelude::*; // RustwardenApp, SecurityHeadersConfig let app = RustwardenApp::builder() .from_env() .security_headers( SecurityHeadersConfig { // App headless ou UI maison sans Alpine : on peut durcir. content_security_policy: "default-src 'self'; script-src 'self'; style-src 'self'; \ img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; \ form-action 'self'" .into(), ..SecurityHeadersConfig::default() } // Équivaut à COOP: same-origin + CORP: same-origin. .with_same_origin_isolation(), ) .build() .await?;
Détails d'implémentation utiles à connaître :
- la config est capturée dans un
Arcà la construction du layer, pas lue depuis uneExtension. Le layer d'en-têtes est plus externe que lesExtension(...): au moment où la requête le traverse, les extensions ne sont pas encore posées ; - une CSP syntaxiquement invalide (caractères de contrôle) ne panique pas : elle est remplacée par le défaut avec un
warn!; - une valeur COOP/CORP invalide fait sauter l'en-tête avec un
warn!, sans casser la réponse ; frame-ancestors 'none'+X-Frame-Options: DENYcouvrent le clickjacking sur les navigateurs modernes comme legacy.
Avant d'activer with_same_origin_isolation(), vérifier qu'aucune intégration cross-origin (iframe, CDN d'assets, widget tiers) n'en dépend.
Open redirect
Le paramètre redirect du middleware auth_required est sanitisé avant utilisation. La protection appliquée :
- La valeur doit commencer par
/(chemin absolu sur le même domaine). - La valeur ne doit pas commencer par
//(protocole relatif — cela pointerait vers un domaine externe). - Les octets nuls sont rejetés.
Toute valeur qui ne passe pas ces contrôles est remplacée par le chemin par défaut (/). Cette protection empêche les redirections vers des domaines externes après login.
Session storage
Rustwarden utilise tower-sessions pour la gestion de session. Trois backends sont disponibles :
| Backend | Feature Cargo | État |
|---|---|---|
MemoryStore | (défaut, aucune feature) | Fonctionnel — perte des sessions au redémarrage |
SeaOrmSessionStore | (intégré dans le core) | Fonctionnel — sessions persistantes en PostgreSQL |
RedisSessionStore | redis-session | Cassé — ne compile pas, éviter --all-features |
En production, le backend SeaORM est recommandé pour la persistance et la compatibilité multi-instance (à condition d'utiliser la session comme état de passage uniquement, pas comme cache d'autorisation).
Sécurité email
Le transport SMTP et la lecture IMAP (détection des bounces) exposent deux options de configuration potentiellement dangereuses :
accept_invalid_certs(SMTP) : accepte les certificats TLS auto-signés ou expirés.accept_invalid_certs(IMAP) : même comportement pour la lecture des bounces.
Quand ces options sont activées, un warning est journalisé au démarrage :
WARN rustwarden_core: SMTP accept_invalid_certs=true — do not use in production
Ces options ne doivent jamais être activées en production. Elles existent uniquement pour faciliter le développement local avec des serveurs SMTP internes (ex. MailHog) qui n'ont pas de certificat valide.
Audit trail
Le service auth_logging enregistre les événements d'authentification et d'administration dans la table audit_logs.
sequenceDiagram
participant H as Handler Axum
participant AL as auth_logging service
participant DB as audit_logs
H->>H: Action métier (login, logout, changement de rôle...)
H->>AL: log_action(db, user_id, action, target_user_id, level, message)
AL->>DB: INSERT audit_logs (id, user_id, action, target_user_id, level, message, created_at)
Note right of DB: ON DELETE SET NULL sur user_id et target_user_id\nLes logs persistent après suppression d'un utilisateur
Les colonnes actor_remote_identity_id et target_remote_identity_id (ajoutées par la migration fediverse) permettent d'enregistrer des actions impliquant des identités distantes tout en maintenant la même structure de table.
Modèle de menace STRIDE
| Catégorie STRIDE | Exemples de menaces | Contrôles en place |
|---|---|---|
| Spoofing (usurpation) | Token JWT forgé, réutilisation d'un token révoqué, phishing OAuth | Signature HS256, blacklist JWT, validation state OAuth |
| Tampering (altération) | Modification des claims JWT, corruption des secrets chiffrés | Signature HMAC, AES-GCM avec tag d'intégrité |
| Repudiation (répudiation) | Nier une action d'administration ou un login | Table audit_logs avec ON DELETE SET NULL |
| Information Disclosure (fuite d'info) | Exposition du hash de mot de passe, secrets OAuth en DB, timing oracles | bcrypt, AES-GCM, subtle::ConstantTimeEq, erreurs opaques |
| Denial of Service (déni de service) | Brute-force login, spam de reset password, remplissage de la blacklist | Rate limiter (max 100 000 entrées), cleanup tasks |
| Elevation of Privilege (élévation) | Contournement RBAC, lien OAuth sans email vérifié, PAT avec scope trop large | require_permission!, vérification email_verified, scopes explicites |
Référence aux audits existants
- ANSSI_COMPLIANCE_REPORT.md — conformité aux recommandations ANSSI Rust
- ANSSI_FEDIVERSE_COMPLIANCE.md — conformité ANSSI pour le module fediverse
- SECURITY_AUDIT_2026_05.md — dernier audit de sécurité (mai 2026), 10 findings priorisés
- fediverse-security-model.md — modèle de sécurité spécifique à ActivityPub
GitRust