SobreCompetênciasTrabalhoBlogCredenciaisContacto
← Voltar ao blog Azure & Cloud

Enviar email de uma aplicação, sem uma única password

A Microsoft desligou o SMTP com password. A alternativa não é guardar um segredo melhor: é não guardar segredo nenhum. Identidade gerida, o Microsoft Graph, e o Mail.Send preso a um grupo de caixas. Contamos a configuração toda, incluindo as armadilhas que nos custaram uma manhã.

pH7x Systems® · · 5 min de leitura

Uma aplicação precisa de enviar email. Uma confirmação, um alerta, um recibo. A primeira solução que aparece é a errada: uma caixa de correio, uma password, e essa password metida numa variável de ambiente.

Funciona no primeiro dia. E é uma bomba com temporizador: a Microsoft está a desligar o SMTP com password, o segredo vaza com o código ou com o backup, e quem o apanhar envia em nome da empresa. Não tem de ser assim: uma aplicação pode enviar emails sem guardar password nenhuma. Aqui fica a configuração toda, do princípio ao fim, com as armadilhas que nos custaram uma manhã pelo caminho.

O problema: a password que não devia existir

O caminho antigo é simples de escrever e difícil de defender. O segredo vive algures, e algures é sempre um sítio a mais.

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 });

Por baixo há um problema maior. A Microsoft desativa o SMTP AUTH por omissão nos tenants novos, e está a removê-lo dos antigos. O código acima deixa de funcionar sem aviso, e a reação instintiva, reativar o SMTP AUTH, abre exatamente a porta que a Microsoft anda a fechar.

O caminho certo: identidade em vez de segredo

A aplicação corre no Azure, e o Azure sabe quem ela é. Chama-se identidade gerida: a plataforma dá à aplicação um token, a pedido, sem que exista password nenhuma em lado nenhum. Não há segredo para guardar, para rodar, nem para vazar.

O email sai pelo Microsoft Graph, não por SMTP. Aceita esse token, verifica que a identidade tem autorização, e entrega. Toda a autenticação passa a ser problema da plataforma, e deixa de ser um ficheiro nosso.

O código

São menos linhas do que a versão com password, e falta-lhe o campo que interessa a um atacante. O DefaultAzureCredential trata do token: na cloud usa a identidade gerida, na nossa máquina usa a sessão do 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()}`);
}

O mesmo código corre nos dois sítios, e em nenhum há uma password escrita. Falta a parte menos óbvia: dizer ao Exchange que esta identidade só pode enviar de uma caixa, e de mais nenhuma.

Prender o envio a um grupo de caixas

A permissão Mail.Send do Microsoft Graph, concedida sozinha, deixa a aplicação enviar em nome de qualquer caixa do tenant, a do administrador incluída. A restrição faz-se no Exchange, com RBAC para aplicações. E o âmbito não deve ser uma caixa fixa, mas um grupo: assim acrescentamos ou tiramos caixas depois, sem voltar a mexer na atribuição.

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"

Com o grupo criado, registamos a identidade no Exchange, definimos um âmbito que resolve para os membros do grupo, e concedemos o Mail.Send preso a esse âmbito.

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"

O AppId é obrigatório, e não o ObjectId. Trocá-los dá um 403 «Blocked by tenant configured AppOnly AccessPolicy settings», um erro que não diz o que está errado e nos manda procurar no sítio errado.

As armadilhas que custam a segurança toda

A primeira já apareceu: AppId, nunca ObjectId. A segunda é pior, porque a configuração parece certa e não está. As permissões do Entra e do RBAC do Exchange somam-se. Se a identidade tiver um Mail.Send concedido também no Entra, sem âmbito, a união das duas anula a restrição, e a aplicação volta a poder enviar como qualquer caixa.

A regra é uma só: o Mail.Send existe apenas no RBAC do Exchange, restrito ao grupo. No Entra, zero. Se o consentimento lá estiver, tira-se, senão todo o trabalho feito no Exchange não serve de nada.

O tempo de propagação conta

Feita a configuração, testámos, e falhou. Voltámos atrás, revimos tudo, estava certo, e continuava a falhar. O problema não era a configuração: era o tempo. As alterações de RBAC no Exchange Online não são imediatas, replicam pelo serviço e podem levar até meia hora a valer em todo o lado.

A lição custou-nos a manhã: depois de mexer no âmbito ou na atribuição, esperar antes de concluir que está mal. O próprio Test-ServicePrincipalAuthorization pode responder com o estado antigo durante essa janela. Mudar mais coisas nesse intervalo é a melhor maneira de partir o que já estava certo.

Como se prova que está trancado

Uma restrição que não se demonstra não conta. O Exchange responde à pergunta diretamente, caixa a caixa, e é esta a prova que guardamos.

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

O que fica

Uma aplicação que envia email, sem uma password em lado nenhum, e que só consegue enviar do grupo que lhe demos. Se for comprometida amanhã, o atacante não encontra segredo para roubar, e não escreve em nome de mais ninguém. Não é um canhão para matar uma mosca: é a permissão mais pequena que resolve o problema, e a prova de que é mesmo a mais pequena.

Continuar a ler