RFC 0036 — Validation charset username (newtype Username)
- Auteur : EBO (origine audit Gitrust / Rustwarden 1.0.1)
- Date : 2026-09-18
- Statut : Implemented (2026-10-01 ; Accepted 2026-09-18)
- Apps consommatrices concernées : Gitrust (newtype local à retirer après Accept + impl) ; tout consommateur qui appelle
UserService::create_user/update_profileouPOST /api/v1/auth/register - Crate cible :
rustwarden-core - Origine : constat M-1 du plan
docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md(Task 13)
Summary
Introduire un newtype Username calqué sur TeamSlug (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.
Motivation
Problème concret (audit M-1)
Aujourd’hui un username n’est borné que par la longueur :
RegisterRequest:#[validate(length(min = 3, max = 64))]— aucun charset.UserService::create_user: insert SeaORM sans parse (la longueur n’est même pas re-vérifiée).UserService::update_profile: uniquementlen() < 3(et unicité). Pas de max, pas de charset.
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.
Qui est impacté
- Gitrust maintient déjà un newtype local
Usernamepour 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. - Tout appelant de
UserService::create_user: register HTTP, admin UI (rustwarden-ui),ensure_default_admin, OAuth (generate_unique_username),seed, tests de services. - Les utilisateurs qui se renomment via
PUT /settings/profile(update_profile).
Si on ne fait rien
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.
Urgence
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).
Non-goals
- Pas de migration SQL qui réécrit
users.usernameexistants, ni deCHECKsur la colonne. - Pas de patch 1.0.1 : ce document est Draft ; l’implémentation commence seulement après
Accepted. - Pas de charset e-mail (RFC 0038 pour le changement d’e-mail / revalidation).
- Pas de fold silencieux
to_ascii_lowercase:Aliceest rejeté, pas normalisé enalice. - Pas de validation au login (
AuthService::authenticate_user,get_user_by_username) : un compte grandfathered doit encore s’authentifier. - Pas de changement de signature de
create_user/update_profile(restentString) : le parse est interne. - Pas de newtype sur les lectures (
user::Model.usernameresteString). - Pas de réserve de
robots.txt: ce n’est pas un username (le point.est hors charset).
API proposée
Nouveau fichier crates/rustwarden-core/src/types/username.rs, exporté comme TeamSlug (types/mod.rs + prelude).
Règles de Username::parse (après trim, sans fold de casse) :
| Règle | Accepté | Rejeté |
|---|---|---|
| Longueur | 3–39 | ab, 40+ caractères, vide / blancs seuls |
| Charset | [a-z0-9-] | majuscule (Alice), _, ., espace, backtick |
| Hyphen | squad-42 | -alice, alice-, alice--bob |
| Réservés | — | team_slug::RESERVED plus me, explore |
robots.txt échoue sur le charset (.), pas sur la liste réservée.
Liste réservée actuelle de team_slug::RESERVED (constante de module, pas un associated const TeamSlug::RESERVED) :
admin, api, auth, health, login, logout, new, oauth, register, settings, signin, signout, signup, static, teams.
À 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.
Newtype
// crates/rustwarden-core/src/types/username.rs use crate::error::AppError; use crate::types::team_slug; /// Extra reserved logins on top of [`team_slug::RESERVED`]. pub const EXTRA_RESERVED: &[&str] = &["me", "explore"]; /// Validated local username. Construct only via [`Username::parse`]. #[derive(Debug, Clone, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] #[serde(transparent)] pub struct Username(String); impl Username { /// Parse a raw string into a `Username`. /// /// Trims ASCII/Unicode whitespace, then enforces charset and reserved /// names. Uppercase is rejected (no silent fold) so `Alice` and `alice` /// cannot collide. Returns [`AppError::Validation`] (HTTP 400). pub fn parse(raw: &str) -> Result<Self, AppError> { let s = raw.trim(); if s.len() < 3 || s.len() > 39 { return Err(AppError::Validation( "Username must be 3-39 characters".into(), )); } if s.starts_with('-') || s.ends_with('-') { return Err(AppError::Validation( "Username cannot start or end with '-'".into(), )); } if s.contains("--") { return Err(AppError::Validation( "Username cannot contain consecutive '-'".into(), )); } if !s .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err(AppError::Validation( "Username must only contain lowercase letters, digits, and '-'".into(), )); } if team_slug::RESERVED.contains(&s) || EXTRA_RESERVED.contains(&s) { return Err(AppError::Validation(format!("Username '{s}' is reserved"))); } Ok(Self(s.to_string())) } /// Return the username as a string slice. pub fn as_str(&self) -> &str { &self.0 } /// Consume the `Username` and return the inner `String`. pub fn into_inner(self) -> String { self.0 } } impl std::fmt::Display for Username { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } }
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).
Frontières d’écriture (signatures actuelles, inchangées)
Signatures aujourd’hui (à conserver) :
// crates/rustwarden-core/src/services/user_service.rs impl UserService { pub async fn create_user( db: &DatabaseConnection, username: String, email: String, password_hash: String, must_change_password: bool, ) -> Result<user::Model, AppError> { /* ... */ } pub async fn update_profile( db: &DatabaseConnection, user_id: Uuid, username: Option<String>, email: Option<String>, ) -> Result<user::Model, AppError> { /* ... */ } } // crates/rustwarden-core/src/auth/handlers.rs #[derive(Debug, Deserialize, Validate)] pub struct RegisterRequest { /// Desired login name (3–39 characters after this RFC; currently 3–64). #[validate(length(min = 3, max = 39))] pub username: String, #[validate(email)] pub email: String, #[validate(length(min = 8))] pub password: String, }
Comportement après Accept :
create_userappelleUsername::parse(&username)?avant l’insert. LeStringpersisté estparse(...).into_inner()(déjà trimé).update_profileappelleUsername::parseseulement siusernameestSomeet 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.registergardepayload.validate()(longueur) etUsername::parse(&payload.username)?avantcreate_user(défense en profondeur ;create_userreste la porte unique même si un autre handler bypasseRegisterRequest).
Usage consommateur
use rustwarden_core::error::AppError; use rustwarden_core::types::Username; fn accept_login_name(raw: &str) -> Result<String, AppError> { Ok(Username::parse(raw)?.into_inner()) } async fn create_via_service( db: &sea_orm::DatabaseConnection, ) -> Result<(), AppError> { // create_user still takes String; invalid values now return AppError::Validation. let _user = rustwarden_core::services::user_service::UserService::create_user( db, "alice".into(), "alice@example.com".into(), hashed_password(), false, ) .await?; Ok(()) } fn hashed_password() -> String { unimplemented!("placeholder for the RFC usage sketch") }
hashed_password() est un placeholder de documentation : ne pas copier en production.
Call sites à adapter dans le même PR d’implémentation
Sans changer leur API publique, ces chemins cassent si create_user parse et qu’ils produisent encore _ / majuscules / admin :
| Call site | Motif actuel | Action après Accept |
|---|---|---|
OAuthService::generate_unique_username | filtre alphanumeric || '_' , pas de lowercase | émettre un candidat qui passe Username::parse (hyphen, minuscule, ≤ 39) |
AdminConfig::validate | 3–64, [A-Za-z0-9_-] | déléguer à Username::parse (voir Open questions admin) |
ensure_default_admin | create_user(config.username) défaut "admin" | "admin" est dans team_slug::RESERVED |
rustdoc register / JWT examples | john_doe | remplacer par john-doe / alice |
seed.rs (richie, fonzie, …) et les tests qui créent alice restent valides.
Analyse de compatibilité
Checklist :
- API existante non modifiée → non-breaking (patch/minor possible).
-
API existante étendue avec défaut → non-breaking (minor) — ajout de
Username+ re-export prelude. -
API existante modifiée ou supprimée → breaking comportemental (payloads create/register/rename autrefois
200/201→400). Signaturescreate_user/update_profile/RegisterRequest.username: Stringinchangé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). - Nouveau comportement par défaut → create / rename / register refusent charset hors GitHub-like. Login inchangé.
-
Changement de schéma DB → non : pas de
CHECK, pas de rewrite. Grandfathering volontaire. -
Changement de format sérialisé → JSON register/profil : la valeur acceptée est plus stricte (
max64 → 39, plus d’underscore). Pas de nouveau champ.
Consommateurs : Gitrust pourra supprimer son newtype dupliqué après bump. Les apps qui créent John_Doe devront adapter.
Alternatives considérées
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.
Alternative A (recommandée) : newtype Username + parse aux frontières d’écriture
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é.
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).
Alternative B : regex uniquement sur RegisterRequest
Pros : diff minimal (validator + une regex). Pas de nouveau type public.
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.
Alternative C (ne rien faire / garder le workaround) : newtype Gitrust forever
Pros : zéro coût, zéro risque upstream. Comptes exotiques restent créables pour les apps qui le veulent.
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 ».
Testing plan
Tests unitaires obligatoires (copier la batterie team_slug.rs tests vers ~L76, plus les règles username-only) :
- Happy path :
Username::parse("alice")et"squad-42"→as_str()identique ; trim" alice "→"alice". - Erreur 1 — input invalide charset :
Username::parse("x`id`y"),"john_doe","alice.bob","Alice"(majuscule, pas de fold). - Erreur 2 — contrainte violée forme :
"","ab"(< 3), 40 caractères,"-alice","alice-","alice--bob". - Erreur 3 — reserved : chaque entrée de
team_slug::RESERVED, plusmeetexplore.robots.txtestErrvia charset, pas besoin d’être dansEXTRA_RESERVED.
Tests d’intégration (service / DB) :
create_userrefusex`id`yavant insert (AppError::Validation) ; aucun rowusers.create_user("alice")happy path (MockDatabase ou DB de test existante).- 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. - Rename :
update_profileversNot-Valid/Admin→Validation; versbobunique → OK.
Tests de non-régression :
- Code consommateur pré-RFC compile (signatures inchangées).
- Login / JWT / refresh : pas d’appel à
Username::parsesur le chemin lecture.
Doctests :
- Au moins un exemple copie/collable sur
Username::parse(aliceOK,Aliceis_err()), exécuté comme doctest.
Benchs : non (parse CPU-bound trivial, hors hot path auth bcrypt/JWT). Pas de seuil 10 %.
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.
Migration guide
Section à copier telle quelle dans le CHANGELOG.md au moment du merge.
### API changes **Before (v1.0.x)** : `UserService::create_user` / `RegisterRequest` acceptaient tout `String` de 3–64 caractères (underscores, majuscules, backticks). `update_profile` ne vérifiait que `len() >= 3`. **After (vX.Y.Z)** : Nouveau type public `rustwarden_core::types::Username`. `create_user`, `update_profile` (rename) et `POST /api/v1/auth/register` appellent `Username::parse` : `^[a-z0-9-]{3,39}$`, pas de `--`, pas de `-` en tête/queue, pas de majuscule (pas de fold), noms réservés (`team_slug::RESERVED` + `me` + `explore`). Les comptes déjà en base hors charset restent valides au login. Pas de `CHECK` SQL. ```rust // Optional explicit parse in a consumer handler let username = rustwarden_core::types::Username::parse(&raw)?.into_inner(); UserService::create_user(db, username, email, hash, false).await?;
Migration steps
- Bump
rustwarden-coreà la version qui livre RFC 0036. - Auditer les usernames créés par l’app (fixtures, OAuth, CLI, seeds) :
remplacer
_par-, lowercaser, tronquer à 39, éviteradmin/me/explore. - Gitrust : supprimer le newtype local
Usernameau profit derustwarden_core::types::Username(update-project/ bump path). - Ne pas lancer de rewrite SQL des comptes existants.
- Vérifier
ADMIN_USERNAME: la valeur par défaut"admin"est réservée (voir RFC 0036 Open questions) — définir un login non réservé sur les nouvelles installs.
## Open questions Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus : - [x] **`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. **Résolu (2026-09-18) : Option (1) durcie : `admin` reste réservé au register/rename publics ; le bootstrap `ensure_default_admin` passe par un chemin interne `pub(crate)` (parse avec charset strict mais noms réservés autorisés) ; `AdminConfig::validate` délègue à ce même chemin. Les installs par défaut (`ADMIN_USERNAME=admin`) continuent de fonctionner.** - [x] **SemVer** : breaking comportemental sans changement de signature → major, ou minor `Security` documenté ? Le processus §1 penche major. **Résolu (2026-09-18) : 1.1.0 minor avec section `Security` au CHANGELOG (framework vendored, breaking comportemental documenté).** - [x] **`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. **Résolu (2026-09-18) : confirmé — skip du parse quand le username soumis est identique au username courant (pas un rename).** - [x] **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). **Résolu (2026-09-18) : aligné dans le même PR d’implémentation (candidats lowercase, hyphens, ≤ 39, hors réservés).** - [x] **Tracking GitHub** : ouvrir l’issue avant `Under Review`. **Résolu (2026-09-18) : suivi interne via docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md, pas d’issue externe.** ## Tracking issue À 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. --- ## Score d'évaluation (rempli par les reviewers) Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35. | Critère | Note (1-5) | Commentaire | |---|---|---| | Généralité | 5 | Tout consommateur de `UserService::create_user` en bénéficie ; Gitrust retire un newtype local. | | Stabilité API | 4 | Signatures inchangées, mais payloads autrefois 200/201 passent en 400 (underscore, casse, max 64→39). | | Performance | 5 | Parse CPU trivial hors bcrypt/JWT ; aucun I/O, aucun bench requis. | | Sécurité | 5 | Ferme M-1 (backticks, collision `Alice`/`alice`, usurpation de segments) sans casser le login grandfathered. | | Complexité d'implémentation | 4 | Un fichier newtype + 3 call sites + OAuth/admin à aligner ; pas d’`unsafe`, pas de migration DB. | | Charge de maintenance | 5 | Zéro nouvelle dépendance ; une liste réservée partagée avec `TeamSlug` (test de non-drift). | | Documentation | 4 | Rustdoc + doctest + CHANGELOG + ce RFC ; examples rustdoc `john_doe` à corriger à l’impl. | | **Total** | **32/35** | (≥ 25 pour Accepted) |
GitRust