SÉCURITÉ (HIGH): changement d'email marqué « vérifié » sans preuve de possession → prise de compte fédérée #63

Open
opened 2026-07-04 12:12:02 +00:00 by momsse · 0 comments
Owner

Migré depuis viziertronic/octant#145 — ouvert le 2026-07-03 par @momsse.

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, puis confirmEmailChange()sans token — émet directement UserEmailChangedEvent + 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 PK email ne protège rien.

Fichiers concernés

  • packages/domain/authentication/src/interface-adapters/rpcs/authentication.rpc.ts:156-164confirmEmailChangeRpc a payload: Schema.Void (aucun token).
  • packages/domain/authentication/src/application/aggregates/user.aggregate.ts:697-725decideConfirmEmailChange émet UserEmailChangedEvent + UserEmailVerifiedEvent sur la seule condition pendingEmail !== null.
  • packages/domain/authentication/src/application/aggregates/user.aggregate.ts:669-695decideRequestEmailChange ne teste que state.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-129remapEmailLookup : DELETE … WHERE user_id puis upsert ON 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é consomme findUserByEmail sur cette table empoisonnée.

Chaîne d'exploitation

Vecteur 1 — pré-hijacking fédéré (le plus grave).

  1. L'attaquant se connecte (Discord), appelle requestEmailChange({ newEmail: "victime@corp.com" }) puis confirmEmailChange(). Son compte A porte désormais victime@corp.com, marqué vérifié → user_email_lookup: victime@corp.com → A.
  2. Plus tard, la victime se connecte via Google (email vérifié, provider jamais lié) : 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), le DO UPDATE SET user_id réécrit la ligne en victime@corp.com → A. Les providers déjà liés de la victime continuent de fonctionner (lookup par provider_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-lookup est 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 en ON CONFLICT (email) DO NOTHING + remonter une erreur quand l'adresse cible est déjà mappée à un autre user_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ée EmailAlreadyInUseError.
  • Ne plus émettre UserEmailVerifiedEvent ni é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é ». confirmEmailChange sans preuve ne doit pas marquer vérifié.

Phase 2 — Vérification hors-bande (nécessite une brique mail)

  • Introduire un port MailSender (service applicatif) + un adaptateur infra.
  • requestEmailChange gé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. UserEmailVerifiedEvent n'est émis que sur cette preuve.
  • Signaux de sécurité : publier un signal sur tentative de changement vers une adresse déjà prise / token invalide.

Tests

  • Agrégat : confirmEmailChange sans token/preuve ne doit pas produire UserEmailVerifiedEvent.
  • Agrégat : requestEmailChange vers une adresse tierce → EmailAlreadyInUseError.
  • Intégration : le vecteur 1 (pré-hijacking) et le vecteur 2 (corruption via DO UPDATE) ne sont plus reproductibles.
  • Round-trip token (CSPRNG, TTL, usage unique, constant-time).

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é.

> _Migré depuis [viziertronic/octant#145](https://github.com/viziertronic/octant/issues/145) — ouvert le 2026-07-03 par @momsse._ ## 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, puis `confirmEmailChange()` — **sans token** — émet directement `UserEmailChangedEvent` + `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 PK `email` ne protège rien. ### Fichiers concernés - `packages/domain/authentication/src/interface-adapters/rpcs/authentication.rpc.ts:156-164` — `confirmEmailChangeRpc` a `payload: Schema.Void` (aucun token). - `packages/domain/authentication/src/application/aggregates/user.aggregate.ts:697-725` — `decideConfirmEmailChange` émet `UserEmailChangedEvent` + `UserEmailVerifiedEvent` sur la seule condition `pendingEmail !== null`. - `packages/domain/authentication/src/application/aggregates/user.aggregate.ts:669-695` — `decideRequestEmailChange` ne teste que `state.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_id` puis upsert `ON 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é consomme `findUserByEmail` sur cette table empoisonnée. ## Chaîne d'exploitation **Vecteur 1 — pré-hijacking fédéré (le plus grave).** 1. L'attaquant se connecte (Discord), appelle `requestEmailChange({ newEmail: "victime@corp.com" })` puis `confirmEmailChange()`. Son compte A porte désormais `victime@corp.com`, marqué vérifié → `user_email_lookup: victime@corp.com → A`. 2. Plus tard, la victime se connecte via Google (email vérifié, provider jamais lié) : `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`), le `DO UPDATE SET user_id` réécrit la ligne en `victime@corp.com → A`. Les providers **déjà liés** de la victime continuent de fonctionner (lookup par `provider_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-lookup` est **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 en `ON CONFLICT (email) DO NOTHING` + remonter une erreur quand l'adresse cible est déjà mappée à un autre `user_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ée `EmailAlreadyInUseError`. - [ ] Ne plus émettre `UserEmailVerifiedEvent` ni é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é ». `confirmEmailChange` sans preuve ne doit pas marquer vérifié. ### Phase 2 — Vérification hors-bande (nécessite une brique mail) - [ ] Introduire un port `MailSender` (service applicatif) + un adaptateur infra. - [ ] `requestEmailChange` gé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. `UserEmailVerifiedEvent` n'est émis que sur cette preuve. - [ ] Signaux de sécurité : publier un signal sur tentative de changement vers une adresse déjà prise / token invalide. ### Tests - [ ] Agrégat : `confirmEmailChange` sans token/preuve ne doit pas produire `UserEmailVerifiedEvent`. - [ ] Agrégat : `requestEmailChange` vers une adresse tierce → `EmailAlreadyInUseError`. - [ ] Intégration : le vecteur 1 (pré-hijacking) et le vecteur 2 (corruption via `DO UPDATE`) ne sont plus reproductibles. - [ ] Round-trip token (CSPRNG, TTL, usage unique, constant-time). ## 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é.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
momsse/octant#63
No description provided.