auth : sign-in/email dérive un mot de passe de taille non bornée, sur la seule route anonyme #89

Closed
opened 2026-09-22 09:51:11 +00:00 by nicolas-fryder · 0 comments

Ce qui se passe

creerAuth pose emailAndPassword: { enabled, disableSignUp, minPasswordLength }. Il ne pose pas maxPasswordLength.

Sur POST /api/auth/sign-in/email (better-auth/dist/api/routes/sign-in.mjs, v1.7.5), aucune longueur n'est vérifiée avant la dérivation :

const { email, password } = ctx.body
if (!z.email().safeParse(email).success) throw APIError.from('BAD_REQUEST', INVALID_EMAIL)
const userRecord = await ctx.context.internalAdapter.findUserByEmail(email.toLowerCase(), …)
const credentialAccount = userRecord?.accounts.find(…)
if (!userRecord || !credentialAccount) {
  await ctx.context.password.hash(password)   // ← adresse inconnue : on dérive quand même
  throw APIError.from('UNAUTHORIZED', INVALID_EMAIL_OR_PASSWORD)
}

La dérivation sur adresse inconnue est délibérée et bonne : elle égalise les temps de réponse, pour qu'ils ne distinguent pas un compte existant d'un compte absent. C'est le manque de borne qui pose problème, pas elle.

Le plafond par défaut de better-auth (128) n'intervient qu'à l'inscription et au changement de mot de passe, où les deux contrôles de longueur précèdent bien le hachage. Pas à la connexion.

Pourquoi ça compte plus qu'ailleurs

C'est la seule route que n'importe qui peut appeler sans identité. Un POST anonyme portant dix mégaoctets déclenche scrypt sur dix mégaoctets, et le throttle (30/min/IP) borne le nombre d'appels, pas le coût de chacun.

ATLAS avait exactement ce défaut avant le rétrofit et l'avait fermé par un plafond de 256 caractères, vérifié avant tout appel au KDF. Le rétrofit le rouvrait donc, au seul endroit où il est atteignable sans compte — une régression qu'un satellite adopte sans la voir, puisqu'elle vient d'en dessous.

Deux façons de le fermer, et je n'ai pas d'avis tranché

  1. maxPasswordLength dans creerAuth. Une ligne, et ça cadre avec minPasswordLength déjà posé — le socle décide déjà de l'un, décider de l'autre au même endroit se lit bien. Mais ça ne protège que le champ password : callbackURL, rememberMe et ce que les greffons ajoutent restent non bornés.
  2. Borner le corps de toutes les routes de /api/auth. Plus large, et ça se pose dans porteAuth côté Nest. Pour Next il n'y a pas d'équivalent aujourd'hui (voir plus bas), donc chaque satellite le referait.

Contourné

ATLAS borne le corps dans sa route attrape-tout (src/app/api/auth/[...all]/route.ts) : lecture bornée sur les octets réellement lus — jamais sur Content-Length, un en-tête fourni par le client ne protège de rien — puis retransmission d'une Request neuve à auth().handler. Plafond à 16 KiB, très au-dessus d'un corps légitime.

Rien à retirer si le socle pose maxPasswordLength : la borne du corps couvre plus que le seul champ.

En passant

porteAuth n'existe que pour Express/Nest. Un satellite Next doit réappliquer ROUTES_AUTH_FERMEES à la main dans son attrape-tout — c'est fait dans ATLAS, sur la même liste, mais c'est un endroit de plus où oublier ferme moins que prévu. Un équivalent porteAuthNext dans @eqs/auth/next rendrait les deux plateformes symétriques. À voir si ça vaut un ticket séparé.

Versions : @eqs/auth@0.12.2, better-auth@1.7.5.

## Ce qui se passe `creerAuth` pose `emailAndPassword: { enabled, disableSignUp, minPasswordLength }`. Il ne pose **pas** `maxPasswordLength`. Sur `POST /api/auth/sign-in/email` (`better-auth/dist/api/routes/sign-in.mjs`, v1.7.5), aucune longueur n'est vérifiée avant la dérivation : ```js const { email, password } = ctx.body if (!z.email().safeParse(email).success) throw APIError.from('BAD_REQUEST', INVALID_EMAIL) const userRecord = await ctx.context.internalAdapter.findUserByEmail(email.toLowerCase(), …) const credentialAccount = userRecord?.accounts.find(…) if (!userRecord || !credentialAccount) { await ctx.context.password.hash(password) // ← adresse inconnue : on dérive quand même throw APIError.from('UNAUTHORIZED', INVALID_EMAIL_OR_PASSWORD) } ``` La dérivation sur adresse inconnue est **délibérée et bonne** : elle égalise les temps de réponse, pour qu'ils ne distinguent pas un compte existant d'un compte absent. C'est le manque de borne qui pose problème, pas elle. Le plafond par défaut de better-auth (128) n'intervient qu'à l'inscription et au changement de mot de passe, où les deux contrôles de longueur précèdent bien le hachage. Pas à la connexion. ## Pourquoi ça compte plus qu'ailleurs C'est la **seule route que n'importe qui peut appeler sans identité**. Un `POST` anonyme portant dix mégaoctets déclenche scrypt sur dix mégaoctets, et le throttle (30/min/IP) borne le nombre d'appels, pas le coût de chacun. ATLAS avait exactement ce défaut avant le rétrofit et l'avait fermé par un plafond de 256 caractères, vérifié **avant** tout appel au KDF. Le rétrofit le rouvrait donc, au seul endroit où il est atteignable sans compte — une régression qu'un satellite adopte sans la voir, puisqu'elle vient d'en dessous. ## Deux façons de le fermer, et je n'ai pas d'avis tranché 1. **`maxPasswordLength` dans `creerAuth`.** Une ligne, et ça cadre avec `minPasswordLength` déjà posé — le socle décide déjà de l'un, décider de l'autre au même endroit se lit bien. Mais ça ne protège que le champ `password` : `callbackURL`, `rememberMe` et ce que les greffons ajoutent restent non bornés. 2. **Borner le corps de toutes les routes de `/api/auth`.** Plus large, et ça se pose dans `porteAuth` côté Nest. Pour Next il n'y a pas d'équivalent aujourd'hui (voir plus bas), donc chaque satellite le referait. ## Contourné ATLAS borne le corps dans sa route attrape-tout (`src/app/api/auth/[...all]/route.ts`) : lecture bornée sur les **octets réellement lus** — jamais sur `Content-Length`, un en-tête fourni par le client ne protège de rien — puis retransmission d'une `Request` neuve à `auth().handler`. Plafond à 16 KiB, très au-dessus d'un corps légitime. Rien à retirer si le socle pose `maxPasswordLength` : la borne du corps couvre plus que le seul champ. ## En passant `porteAuth` n'existe que pour Express/Nest. Un satellite Next doit réappliquer `ROUTES_AUTH_FERMEES` à la main dans son attrape-tout — c'est fait dans ATLAS, sur la même liste, mais c'est un endroit de plus où oublier ferme moins que prévu. Un équivalent `porteAuthNext` dans `@eqs/auth/next` rendrait les deux plateformes symétriques. À voir si ça vaut un ticket séparé. Versions : `@eqs/auth@0.12.2`, `better-auth@1.7.5`.
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
Infrastructure/eqs-platform#89
No description provided.