0036-username-charset-validation.md 21088 octets

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_profile ou POST /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 :

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 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.
  • 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.username existants, ni de CHECK sur 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 : Alice est rejeté, pas normalisé en alice.
  • 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 (restent String) : le parse est interne.
  • Pas de newtype sur les lectures (user::Model.username reste String).
  • 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ègleAcceptéRejeté
Longueur3–39ab, 40+ caractères, vide / blancs seuls
Charset[a-z0-9-]majuscule (Alice), _, ., espace, backtick
Hyphensquad-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 :

  1. create_user appelle Username::parse(&username)? avant l’insert. Le String persisté est parse(...).into_inner() (déjà trimé).
  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.
  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).

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 siteMotif actuelAction après Accept
OAuthService::generate_unique_usernamefiltre alphanumeric || '_' , pas de lowercaseémettre un candidat qui passe Username::parse (hyphen, minuscule, ≤ 39)
AdminConfig::validate3–64, [A-Za-z0-9_-]déléguer à Username::parse (voir Open questions admin)
ensure_default_admincreate_user(config.username) défaut "admin""admin" est dans team_slug::RESERVED
rustdoc register / JWT examplesjohn_doeremplacer 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). 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).
  • 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 (max 64 → 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, plus me et explore. robots.txt est Err via charset, pas besoin d’être dans EXTRA_RESERVED.

Tests d’intégration (service / DB) :

  • create_user refuse x`id`y avant insert (AppError::Validation) ; aucun row users.
  • 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_profile vers Not-Valid / Admin → Validation ; vers bob unique → OK.

Tests de non-régression :

  • Code consommateur pré-RFC compile (signatures inchangées).
  • Login / JWT / refresh : pas d’appel à Username::parse sur le chemin lecture.

Doctests :

  • Au moins un exemple copie/collable sur Username::parse (alice OK, Alice is_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

  1. Bump rustwarden-core à la version qui livre RFC 0036.
  2. Auditer les usernames créés par l’app (fixtures, OAuth, CLI, seeds) : remplacer _ par -, lowercaser, tronquer à 39, éviter admin / me / explore.
  3. Gitrust : supprimer le newtype local Username au profit de rustwarden_core::types::Username (update-project / bump path).
  4. Ne pas lancer de rewrite SQL des comptes existants.
  5. 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) |