← All posts

Nous avons audité notre propre serveur d'authentification avant le lancement. Les bugs avaient une forme.

Authagonal·September 3, 2026
securityauditsamloidcmfascimssrfwar-story

Avant de demander à qui que ce soit de nous confier ses connexions, nous avons passé notre propre serveur d'authentification en revue comme l'aurait fait un attaquant : non pas en l'exécutant, mais en le lisant. Il en est sorti une revue écrite de toute la surface : seize sections numérotées, 261 lignes, de SAML à la fédération OIDC, SCIM, l'API d'administration, la MFA et l'outillage de sauvegarde, chaque constat portant une sévérité et un emplacement dans le code. Huit étaient marqués à corriger en priorité. Nous avons corrigé ces huit-là en une soirée, et le reste de la liste une demi-heure plus tard. Deux semaines et demie après, nous avons refait le même exercice sur le plan de contrôle multi-tenant et en avons tiré cinq de plus, clos la veille de notre passage en production.

Voici le chiffre qui reste. La suite de tests annonçait 367 tests sur 367 au vert avant cette soirée, et 367 sur 367 après. Rien n'est passé au rouge, parce que rien dans la suite ne visait quoi que ce soit de tout cela. Les tests prouvent ce que vous avez pensé à tester, et personne n'avait pensé à tester si le retrait d'un attribut XML que la signature ne couvre pas orienterait une assertion rejouée vers une branche plus accommodante.

Lue comme huit bugs sans rapport, une liste pareille ne sert à personne d'autre. Lue comme des formes, c'est trois habitudes, répétées d'un sous-système à l'autre, dans du code écrit par des gens qui avaient lu les spécifications.

Le client_id, l'en-tête forwarded, l'InResponseTo et le claim email

La première habitude consiste à traiter une valeur écrite par l'appelant comme un fait établi au sujet de l'appelant. Les deux constats que la revue a classés Critique tenaient chacun à une seule entrée : un paramètre de requête, et un claim email.

La politique MFA était dérivée du client_id extrait du returnUrl sur le formulaire de connexion, et /connect/authorize ne revérifiait jamais. Pointez la connexion vers un client dont la MFA est désactivée et la session ressortait sans second facteur, puis se dépensait très bien chez le client qui en exigeait un. Celui-là a son propre récit.

Les limites par IP se basaient sur l'adresse distante, et les en-têtes forwarded étaient acceptés depuis 0.0.0.0/0. Une valeur X-Forwarded-For par requête rendait tout contrôle par IP purement indicatif, et réécrivait au passage la colonne IP du journal d'audit. Les détails sont ici.

Le consommateur d'assertions SAML choisissait entre le chemin SP-initiated (consommer l'id de requête, à usage unique) et le chemin IdP-initiated (consulter un cache d'ids d'assertion) uniquement selon que la réponse portait ou non un attribut InResponseTo. Cet attribut se trouve sur l'élément <Response>, qui n'est pas couvert lorsque seule l'assertion est signée, ce qui est le cas courant. Retirez-le et une réponse capturée prenait la branche dont le cache anti-rejeu n'avait jamais été alimenté par la connexion d'origine, toutes les signatures continuant de se vérifier. Pourquoi la signature n'a jamais été le problème.

Et les utilisateurs fédérés de retour étaient résolus par l'e-mail contenu dans l'assertion : n'importe quelle connexion pouvait donc affirmer l'adresse de quelqu'un d'autre et atterrir sur son compte. Celui-là aussi a son article.

L'API d'administration était une usine à fabriquer ses propres identifiants

L'autorisation d'administration reposait sur exactement une chose : un jeton dont le claim scope portait le scope d'administration. Deux endpoints d'administration savaient placer ce scope dans un jeton tout neuf.

POST /api/v1/token, l'endpoint d'usurpation d'identité qu'utilise l'outillage de support, découpait son paramètre de requête scopes et en recopiait le résultat tel quel dans un jeton d'accès et un jeton de rafraîchissement, sans jamais le confronter aux AllowedScopes enregistrés du client. Un seul identifiant d'administration, si éphémère soit-il, frappait donc un jeton de rafraîchissement d'administration à longue durée de vie pour n'importe quel utilisateur et n'importe quel client : un privilège qui survit à la rotation de l'identifiant qui vous inquiétait. La création de client, de son côté, liait un objet client brut directement depuis le corps de la requête, ClientSecretHashes compris : un administrateur pouvait donc enregistrer un client client_credentials détenant le scope d'administration et un hash de secret qu'il connaissait déjà. La liste et la lecture renvoyaient en écho tous les hashs stockés.

L'endpoint d'usurpation refuse désormais le scope d'administration et borne le reste par les AllowedScopes du client lui-même, la création et la mise à jour lient un DTO dépourvu de champ de hash de secret, et aucune réponse ne transporte de hash. Une permission capable de s'émettre elle-même n'est pas une permission.

Un trait d'union, un groupe sans propriétaire, et un e-mail qui fait le travail d'une identité

La deuxième habitude consiste à laisser quelque chose qui contient un nom tenir lieu d'identité.

L'e-mail servait de clé de jointure pour la connexion fédérée : global, présent chez de nombreux fournisseurs, et valant exactement ce que la connexion qui l'affirme veut bien en dire. Le correctif a consisté à résoudre sur le couple fournisseur et sujet, qu'aucune autre connexion ne peut fabriquer.

Dans le plan de contrôle, le nom, c'était le slug du tenant. Le routeur qui transforme un slug en préfixe de table de stockage supprimait les traits d'union : acme-corp et acmecorp produisaient donc le même préfixe, c'est-à-dire le même jeu de tables et la même clé de signature Vault. Deux tenants enregistrables séparément partageant un jeu de tables et une clé de signature. Enregistrez le second et vous regardez les utilisateurs du premier. Le correctif n'a pas été une fonction de préfixe plus maligne. Cela a été d'interdire les traits d'union dans les slugs pour que l'application soit injective, et cela mérite la version longue.

SCIM est celui qui a piqué, parce que la moitié était juste. Chaque lecture et chaque écriture d'utilisateur revérifiait que la ressource avait bien été provisionnée par le client appelant. Les groupes, dans le fichier d'à côté, n'avaient aucun propriétaire : la création n'en apposait jamais, la liste passait null comme filtre de propriétaire et renvoyait tous les groupes de l'environnement, et la lecture, le remplacement, le patch et la suppression acceptaient n'importe quel id. Connaître l'id d'un groupe constituait toute l'autorisation, et là où plusieurs clients de provisionnement partagent un environnement, cela revient à lire et à écrire l'appartenance aux groupes d'un client à l'autre, ce qui est la façon dont les rôles sont accordés. Les groupes portent désormais le client créateur, et chaque lecture et chaque écriture y est cantonnée.

La troisième habitude consiste à faire la chose irréversible avant d'exécuter le contrôle.

L'exemple le plus net est une fonctionnalité que des clients achètent : un tenant enregistre un webhook auquel on demande "autorise-t-on cette connexion ?" au moment de l'authentification, et qu'il peut régler pour imposer la réponse. Le hook s'exécutait sur tous les chemins établissant une session, correctement. Il s'exécutait simplement après SignInAsync. Un refus levait donc une exception, la requête revenait en 500, et le navigateur détenait déjà un cookie de session signé. Tout client qui ignorait l'erreur était connecté, bloqué ou non. Le webhook censé bloquer les connexions ne bloquait rien.

Le correctif tient en une ligne déplacée, six fois : exécuter le hook avant SignInAsync sur le mot de passe, OIDC, SAML, la vérification MFA et l'enrôlement MFA, et avant de frapper des jetons à l'endpoint de jetons. La modification de la bibliothèque et celle du plan de contrôle ont été livrées à deux minutes d'intervalle. Un refus donne maintenant un 403 net sans Set-Cookie, et un test de bout en bout vérifie qu'une connexion refusée n'émet aucun .AspNetCore.Cookies.

Le même ordonnancement est apparu dans trois endroits plus discrets. Les codes d'autorisation étaient lus puis supprimés en deux opérations : deux échanges concurrents d'un même code pouvaient donc passer tous les deux la lecture et obtenir tous les deux des jetons ; le magasin de grants expose désormais TryConsumeAsync, une suppression conditionnelle qui ne renvoie vrai qu'à l'appelant ayant réellement retiré la ligne. La restauration écrivait les entités avant de vérifier quoi que ce soit : le champ FileHashes était documenté comme vérifié pendant la restauration alors qu'il n'était jamais renseigné ni contrôlé, ce qui fait d'une sauvegarde altérée une primitive d'écriture contre la production. Et le webhook Stripe agissait sur les événements avant d'enregistrer qu'il les avait vus : une redistribution at-least-once pouvait donc faire osciller le plan et le statut d'un tenant, ou relancer le recouvrement. Les trois vérifient d'abord, désormais.

Valider l'URL au moment de l'enregistrement ne prouve rien au moment de l'appel

Les clients nous confient des URL : webhooks d'authentification, cibles de sauvegarde, callbacks de rétention. Refuser celle qui résout vers une adresse privée au moment où elle est enregistrée est la protection évidente, et à elle seule elle ne vaut presque rien, parce que le DNS n'est pas une promesse. Un nom qui résout publiquement au moment où vous le validez peut résoudre vers 169.254.169.254 au moment où vous vous connectez.

Le contrôle porteur ne vit donc pas dans le gestionnaire de paramètres. C'est un callback de connexion sur le handler HTTP lui-même. Chaque appel sortant vers une URL fournie par un client résout l'hôte, refuse le loopback, RFC1918, l'unique-local et toute la plage link-local 169.254.0.0/16 où loge le service de métadonnées du cloud, puis ouvre la socket vers cette adresse validée exacte plutôt que vers le nom d'hôte : il ne reste ainsi aucune seconde résolution à détourner. Les redirections automatiques sont désactivées, parce qu'un 302 est une occasion de plus de désigner une cible interne. Le contrôle au moment de l'enregistrement demeure un message d'erreur pour le client, tout ce à quoi il a jamais servi.

Deux endpoints et un fichier de sauvegarde que seul le réseau protégeait

/_internal/cluster/gossip était exposé en anonyme, et son contrôle de secret partagé existait sous forme de commentaire plutôt que de code. /_internal/backchannel-logout était exposé en anonyme avec l'antiforgery désactivé sur le listener public, prenait un corps {"SubjectId": "..."}, et révoquait tous les grants détenus par ce sujet. Ni l'un ni l'autre n'avait la moindre garde en processus. Il n'y avait devant eux que la configuration de l'ingress et, vu le constat sur les en-têtes forwarded plus haut, toute défense fondée sur l'IP était de toute façon usurpable. Les deux exigent désormais un secret partagé comparé en temps constant, ou une adresse source interne quand aucun secret n'est configuré.

Les sauvegardes avaient le même caractère. Sur les hôtes utilisant la source de clés locale, la table des clés de signature contient le scalaire privé EC, et la sauvegarde sérialisait chaque colonne telle quelle dans du JSONL gzippé en clair. Quiconque pouvait lire un fichier de sauvegarde pouvait forger des jetons pour l'émetteur dont il provenait. Cette table est désormais exclue par défaut, avec une activation explicite pour les cibles chiffrées, et chaque fichier de données est haché en SHA-256 dans le manifeste puis vérifié avant que la restauration n'écrive la moindre ligne.

L'endroit où s'exécute un contrôle fait l'essentiel de sa valeur

Aucun de ces cas n'était un contrôle manquant au sens où personne n'y aurait pensé. La MFA était implémentée et testée. Le hook du webhook s'exécutait sur tous les chemins. La validation SSRF existait. Les utilisateurs SCIM étaient correctement cantonnés sur chaque verbe, dans le fichier voisin de celui où les groupes ne l'étaient pas. Dans presque tous les cas, la logique était écrite, correcte, et installée à un endroit où elle ne pouvait pas faire son travail : sur une valeur fournie par l'appelant, à une étape que l'appelant pouvait sauter, après l'état qu'elle était censée empêcher, ou sur la ressource d'à côté.

La question utile, à chaque endpoint, n'est donc pas "est-ce qu'on valide ça". Ce sont trois autres questions. Que prouve réellement cette requête, par opposition à ce qu'elle affirme ? Cet identifiant est-il quelque chose qu'un appelant peut nommer, ou quelque chose que seul le bon appelant peut détenir ? Et est-ce que quoi que ce soit d'irréversible se produit avant que la réponse arrive ? Une revue écrite, menée section par section avec une sévérité sur chaque constat, est avant tout un dispositif pour faire passer ces trois questions à travers le code dont vous êtes certain. Le nôtre l'était. C'était seize sections de certitude, plus huit choses qui seraient devenues l'incident de quelqu'un.

Rien de tout cela n'était atteignable par un client, parce qu'il n'y avait pas encore de clients. C'est l'argument en faveur de cet exercice avant le lancement plutôt qu'après le premier signalement : la même liste, écrite six mois plus tard, devient un calendrier de divulgation au lieu du travail d'une soirée. Si vous voulez la version actuelle des réponses, notre page sécurité expose comment les tenants sont isolés, ce qui est chiffré au repos, et ce que contient réellement une sauvegarde de vos données.