Nous exigions la MFA. Un paramètre de requête l'a désactivée.
Nous avons livré l'authentification multi-facteur. Après le mot de passe, la page de connexion demandait un second facteur, comme on s'y attend. Nous l'avons testée, ça marchait, nous sommes passés à autre chose. Puis, lors d'une passe de pré-lancement sur notre propre serveur d'authentification, nous avons découvert qu'on pouvait sauter toute l'étape en éditant une URL.
Voici le principe. Dans un flux OIDC, votre application envoie l'utilisateur vers /connect/authorize. S'il n'est pas encore connecté, le serveur le renvoie vers la page de connexion avec la destination d'origine glissée dans un paramètre : /login?returnUrl=/connect/authorize?.... Vous saisissez votre mot de passe, vous validez la MFA, et le flux de connexion vous ramène à ce returnUrl, moment où /connect/authorize émet un code d'autorisation, suivi des tokens.
Le bug vivait dans l'ordre des opérations. L'étape du mot de passe vous connectait. Autrement dit, elle posait un cookie authentifié. La MFA était la page suivante de la séquence. Et /connect/authorize, l'endpoint qui distribue réellement les tokens, ne posait qu'une seule question avant de le faire : cette requête est-elle authentifiée ? Il ne demandait jamais si la session avait validé un second facteur. « Authentifié » et « authentifié avec MFA », c'était le même cookie.
Le contournement n'a donc rien d'astucieux. Vous soumettez votre mot de passe. Vous recevez le cookie authentifié. Puis, au lieu de suivre le flux jusqu'à l'invite MFA, vous allez vous-même directement au returnUrl, /connect/authorize?..., l'adresse que le serveur vous a donnée au tout début. Authorize voit un principal authentifié, exécute son unique vérification et émet le code. Le second facteur n'a jamais lieu. L'invite MFA était une étape dans un couloir, et rien ne vous empêchait de passer à côté.
Si ce genre de bug survit aux tests, c'est que le chemin nominal est hermétique. Cliquez dans l'interface comme un humain et vous êtes challengé à chaque fois. La barrière est réelle. Elle est simplement au mauvais endroit. La frontière de sécurité d'un serveur OIDC n'est pas l'écran de connexion. C'est /connect/authorize, le point où une session se transforme en tokens. Toute exigence qui ne vit que dans les pages menant à cette frontière n'a qu'une valeur indicative, parce que quiconque peut formuler la requête sous-jacente peut la formuler directement.
Ce recadrage est le correctif. La MFA n'est pas une étape que vous effectuez. C'est une propriété que la session possède ou non. Nous avons donc arrêté de la modéliser comme une page pour la modéliser comme une claim. Quand vous validez le second facteur, le flux de connexion écrit un marqueur mfa_authenticated dans le cookie d'authentification. /connect/authorize n'accepte plus « authentifié ». Il exige « authentifié et porteur de ce marqueur ». Un cookie obtenu avec le seul mot de passe est désormais inerte au token endpoint : il est renvoyé terminer la MFA quelle que soit l'URL vers laquelle vous le pointez, parce que ce qui lui manque est une claim, pas une visite de page.
La fédération en découle proprement. Quand un utilisateur se connecte via un fournisseur d'identité externe qui a exécuté sa propre MFA, ce fournisseur se porte garant du second facteur, donc la connexion fédérée pose le même marqueur. Les utilisateurs SSO ne sont pas challengés deux fois pour quelque chose que leur IdP a déjà imposé. Le marqueur est l'unique source de vérité, et chaque chemin qui peut légitimement satisfaire la MFA l'écrit.
La leçon transférable n'a rien à voir avec la MFA en particulier. La voici : appliquez un contrôle à la frontière qui accorde la chose que vous protégez, pas à l'étape d'interface censée y mener. Nous avions le contrôle. Nous avions écrit la logique MFA, nous l'avions testée, livrée. Elle était simplement attachée à la partie du système qu'un attaquant n'est pas obligé d'utiliser. Écrans de consentement, acceptation des conditions, step-up pour les scopes sensibles : même mode de défaillance. Si l'endpoint qui émet le credential ne vérifie pas la condition, l'écran qui la demande est une suggestion.
Nous avons attrapé celui-ci avant qu'un seul client ne le rencontre, dans le même audit qui a fait remonter quelques-uns de ses cousins. C'est l'argument pour repasser sur votre propre auth comme si vous l'attaquiez, et en particulier pour demander, à chaque endpoint qui distribue quelque chose de précieux, non pas « l'utilisateur a-t-il suivi le flux » mais « que prouve réellement cette requête ». La nôtre prouvait un mot de passe. Nous la traitions comme la preuve de deux facteurs.
Appliquer la MFA à l'endpoint qui frappe les tokens, et non sur la page qui la demande, c'est toute la différence entre une vraie MFA et une suggestion. Authagonal l'applique là où le credential est émis, si bien qu'une URL de retour modifiée à la main ne mène nulle part.