À proposExpertiseRéalisationsBlogCertificationsContact
← Retour au blog Azure & Cloud

Envoyer un email depuis une application, sans un seul mot de passe

Microsoft a coupé le SMTP avec mot de passe. La réponse n'est pas de stocker un meilleur secret : c'est de ne stocker aucun secret. Identité managée, Microsoft Graph, et Mail.Send lié à un groupe de boîtes. Voici toute la configuration, pièges compris.

pH7x Systems® · · 6 min de lecture

Une application doit envoyer un email. Une confirmation, une alerte, un reçu. La première solution qui vient à l'esprit est la mauvaise : une boîte aux lettres, un mot de passe, et ce mot de passe glissé dans une variable d'environnement.

Ça marche le premier jour. Et c'est une bombe à retardement : Microsoft coupe le SMTP avec mot de passe, le secret fuit avec le code ou avec la sauvegarde, et celui qui le récupère envoie au nom de l'entreprise. Ça n'a pas à être ainsi : une application peut envoyer des emails sans stocker un seul mot de passe. Voici toute la configuration, du début à la fin, avec les pièges qui nous ont coûté une matinée en chemin.

Le problème : le mot de passe qui ne devrait pas exister

Le chemin ancien est facile à écrire et difficile à défendre. Le secret vit quelque part, et quelque part est toujours un endroit de trop.

ts
// Don't do this: the password lives with the code, and leaks with it.
const user = "noreply@company.com";
const pass = process.env.SMTP_PASSWORD;   // a secret that someone has to keep
await smtp.send({ user, pass, to, subject, html });

En dessous il y a un problème plus grand. Microsoft désactive le SMTP AUTH par défaut sur les nouveaux tenants, et le retire des anciens. Le code ci-dessus cesse de fonctionner sans avertissement, et la réaction instinctive, réactiver le SMTP AUTH, ouvre exactement la porte que Microsoft ferme.

Le bon chemin : une identité au lieu d'un secret

L'application tourne sur Azure, et Azure sait qui elle est. Cela s'appelle une identité managée : la plateforme remet à l'application un jeton, à la demande, sans qu'aucun mot de passe n'existe nulle part. Il n'y a pas de secret à stocker, à faire tourner, ni à laisser fuir.

L'email sort par Microsoft Graph, non par SMTP. Microsoft Graph accepte ce jeton, vérifie que l'identité est autorisée, et livre. Toute l'authentification devient un problème de la plateforme, et cesse d'être un fichier à nous.

Le code

C'est moins de lignes que la version avec mot de passe, et il lui manque le champ qui intéresse un attaquant. DefaultAzureCredential s'occupe du jeton : dans le cloud il utilise l'identité managée, sur notre machine il utilise la session az login.

ts
import { DefaultAzureCredential } from "@azure/identity";

const credential = new DefaultAzureCredential();
const SENDER = "hello@company.com";

async function send(to: string, subject: string, html: string): Promise<void> {
  const token = await credential.getToken("https://graph.microsoft.com/.default");
  if (!token) throw new Error("no token for Microsoft Graph");

  const res = await fetch(
    `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(SENDER)}/sendMail`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token.token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        message: {
          subject,
          body: { contentType: "HTML", content: html },
          toRecipients: [{ emailAddress: { address: to } }],
        },
        saveToSentItems: true,
      }),
    },
  );

  if (!res.ok) throw new Error(`sendMail ${res.status}: ${await res.text()}`);
}

Le même code tourne aux deux endroits, et à aucun un mot de passe n'est écrit. Il manque la partie la moins évidente : dire à Exchange que cette identité ne peut envoyer que d'une boîte, et d'aucune autre.

Lier l'envoi à un groupe de boîtes

La permission Mail.Send de Microsoft Graph, accordée seule, laisse l'application envoyer au nom de n'importe quelle boîte du tenant, celle de l'administrateur comprise. La restriction se fait dans Exchange, avec le RBAC pour applications. Et la portée ne doit pas être une boîte fixe, mais un groupe : ainsi on ajoute ou retire des boîtes plus tard, sans retoucher à l'attribution.

powershell
# A mail-enabled security group is the scope: add or remove mailboxes later,
# without ever touching the role assignment again.
New-DistributionGroup -Name "api-senders" -Type Security `
  -PrimarySmtpAddress "api-senders@company.com"

Add-DistributionGroupMember -Identity "api-senders" -Member "hello@company.com"

Le groupe créé, nous enregistrons l'identité dans Exchange, définissons une portée qui résout vers les membres du groupe, et accordons le Mail.Send lié à cette portée.

powershell
# Register the managed identity in Exchange. Use the AppId, never the ObjectId.
New-ServicePrincipal -AppId $appId -ObjectId $objectId -DisplayName "api"

# A scope that resolves to the group's members, and to nobody else.
New-ManagementScope -Name "only-api-senders" `
  -RecipientRestrictionFilter "MemberOfGroup -eq '$groupDn'"

# Grant Mail.Send, restricted to that scope.
New-ManagementRoleAssignment -App $servicePrincipalId `
  -Role "Application Mail.Send" -CustomResourceScope "only-api-senders"

L'AppId est obligatoire, non l'ObjectId. Les échanger donne un 403 «Blocked by tenant configured AppOnly AccessPolicy settings», une erreur qui ne dit pas ce qui ne va pas et nous envoie chercher au mauvais endroit.

Les pièges qui coûtent toute la sécurité

Le premier est déjà apparu : AppId, jamais ObjectId. Le second est pire, car la configuration a l'air juste et ne l'est pas. Les permissions d'Entra et du RBAC d'Exchange s'additionnent. Si l'identité a aussi un Mail.Send accordé dans Entra, sans portée, l'union des deux annule la restriction, et l'application peut de nouveau envoyer comme n'importe quelle boîte.

La règle est unique : Mail.Send n'existe que dans le RBAC d'Exchange, restreint au groupe. Dans Entra, zéro. Si le consentement est là, on le retire, sinon tout le travail fait dans Exchange ne sert à rien.

La propagation prend du temps

La configuration faite, nous avons testé, et ça a échoué. Nous sommes revenus en arrière, avons tout revu, c'était juste, et ça continuait d'échouer. Le problème n'était pas la configuration : c'était le temps. Les changements de RBAC dans Exchange Online ne sont pas immédiats, ils se répliquent à travers le service et peuvent prendre jusqu'à une demi-heure pour valoir partout.

La leçon nous a coûté la matinée : après avoir touché à la portée ou à l'attribution, attendre avant de conclure que c'est faux. Test-ServicePrincipalAuthorization lui-même peut répondre avec l'ancien état pendant cette fenêtre. Changer d'autres choses dans cet intervalle est la meilleure manière de casser ce qui était déjà juste.

Comment on prouve que c'est verrouillé

Une restriction qu'on ne démontre pas ne compte pas. Exchange répond à la question directement, boîte par boîte, et c'est la preuve que nous gardons.

powershell
Test-ServicePrincipalAuthorization -Identity "api" -Resource hello@company.com
# InScope : True    -> can send as hello@, which is what we want

Test-ServicePrincipalAuthorization -Identity "api" -Resource ceo@company.com
# InScope : False   -> cannot, and that is exactly why the CEO mailbox is safe

Ce qui reste

Une application qui envoie des emails, sans aucun mot de passe nulle part, et qui ne peut envoyer que depuis le groupe que nous lui avons donné. Si elle est compromise demain, l'attaquant ne trouve aucun secret à voler, et n'écrit au nom de personne d'autre. Ce n'est pas un canon pour tuer une mouche : c'est la permission la plus petite qui résout le problème, et la preuve qu'elle est vraiment la plus petite.

Poursuivre la lecture