SÉCURITÉ (HIGH): changement d'email marqué « vérifié » sans preuve de possession → prise de compte fédérée #63
Labels
No labels
bug
enhancement
pr-split
question
security
transaction-matcher
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
momsse/octant#63
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Résumé
Le changement d'email self-service marque une adresse arbitraire comme « vérifiée » sans aucune preuve de possession. Combiné au merge fédéré par email à la connexion OAuth, cela permet une prise de compte (pre-hijacking) et la corruption du mapping email→utilisateur.
Sévérité : HIGH (tendance critique) sur le vecteur de pré-hijacking fédéré.
Cause racine
requestEmailChange({ newEmail })accepte n'importe quelle adresse, puisconfirmEmailChange()— sans token — émet directementUserEmailChangedEvent+UserEmailVerifiedEvent. Il n'existe aucune capacité d'envoi d'email dans le repo : ce n'est pas un TODO non câblé, c'est le design actuel.Le lookup email de confiance (celui qu'utilise le merge fédéré) est ensuite écrasé en dernier-écrivain-gagne (
ON CONFLICT (email) DO UPDATE SET user_id), donc la contrainte de PKemailne protège rien.Fichiers concernés
packages/domain/authentication/src/interface-adapters/rpcs/authentication.rpc.ts:156-164—confirmEmailChangeRpcapayload: Schema.Void(aucun token).packages/domain/authentication/src/application/aggregates/user.aggregate.ts:697-725—decideConfirmEmailChangeémetUserEmailChangedEvent+UserEmailVerifiedEventsur la seule conditionpendingEmail !== null.packages/domain/authentication/src/application/aggregates/user.aggregate.ts:669-695—decideRequestEmailChangene teste questate.email === newEmail; aucune vérif que l'adresse est déjà prise par un autre compte.packages/infrastructure/postgres-authentication/src/projections/user-lookup.projector.ts:112-129—remapEmailLookup:DELETE … WHERE user_idpuis upsertON CONFLICT (email) DO UPDATE SET user_id.packages/domain/authentication/src/application/use-cases/authenticate.use-case.ts:250-256— le merge fédéré consommefindUserByEmailsur cette table empoisonnée.Chaîne d'exploitation
Vecteur 1 — pré-hijacking fédéré (le plus grave).
requestEmailChange({ newEmail: "victime@corp.com" })puisconfirmEmailChange(). Son compte A porte désormaisvictime@corp.com, marqué vérifié →user_email_lookup: victime@corp.com → A.findUserByProviderAccountéchoue →findUserByEmail("victime@corp.com")renvoie A → l'identité Google de la victime est fusionnée dans le compte de l'attaquant, que celui-ci garde sous contrôle via Discord.Vecteur 2 — corruption d'un compte existant.
Si la victime a déjà un compte V (
victime@corp.com → V), leDO UPDATE SET user_idréécrit la ligne envictime@corp.com → A. Les providers déjà liés de la victime continuent de fonctionner (lookup parprovider_account_id), mais tout nouveau provider ajouté fusionne chez l'attaquant, le mapping email→user est corrompu, et l'attaquant obtient une revendication « vérifiée » de l'email de la victime.Les deux endpoints sont accessibles à tout utilisateur authentifié (
Policy.withAuthenticatedVisitor, aucun gate admin).Plan de correctif
Phase 1 — Mitigation immédiate (sans dépendance mail)
Le projecteur
user-lookupest inline (même transaction que l'append, cf.postgres.user-inline-projectors.ts), donc une erreur de projection fait échouer la commande atomiquement.remapEmailLookup/upsertEmailLookup: passer enON CONFLICT (email) DO NOTHING+ remonter une erreur quand l'adresse cible est déjà mappée à un autreuser_id→ bloque le vecteur 2 de façon atomique.decideRequestEmailChange: rejeter si l'email est déjà détenu par un autre compte (lookup inline de dedup, cf. convention « write-side sync inline OK »), via une nouvelle erreur typéeEmailAlreadyInUseError.UserEmailVerifiedEventni écrire dans le lookup de merge tant que la possession n'est pas prouvée : découpler « changement demandé » (pending, non vérifié) de « email vérifié ».confirmEmailChangesans preuve ne doit pas marquer vérifié.Phase 2 — Vérification hors-bande (nécessite une brique mail)
MailSender(service applicatif) + un adaptateur infra.requestEmailChangegénère un token à usage unique, CSPRNG, TTL borné (Duration), stocké côté agrégat (hash uniquement, jamais en clair), envoyé hors-bande à la nouvelle adresse.confirmEmailChange(token): le payload porte le token, comparaison constant-time dans l'agrégat, usage unique + expiration.UserEmailVerifiedEventn'est émis que sur cette preuve.Tests
confirmEmailChangesans token/preuve ne doit pas produireUserEmailVerifiedEvent.requestEmailChangevers une adresse tierce →EmailAlreadyInUseError.DO UPDATE) ne sont plus reproductibles.Contexte
Trouvé lors de l'audit de sécurité du domaine
auth*. L'ingestion d'email OAuth est saine (les providers ne fournissent que des emails vérifiés) — c'est le use-case self-service de changement d'email qui empoisonne, en aval, le lookup de confiance consommé par le merge fédéré.