Privacidade Por Equipe DevBras

Capa do artigo: "Esqueci a senha" sem limite vira máquina de spam com o seu domínio

"Esqueci a senha" sem limite vira máquina de spam com o seu domínio

Coloque limite por e-mail e por IP no fluxo de recuperação de senha, responda sempre a mesma frase, envie fora da requisição e monitore o volume.

Segunda-feira, 9h. O suporte da escola de idiomas recebe a reclamação de uma aluna: 340 e-mails de "redefina sua senha" desde a madrugada. Na mesma manhã, o provedor de envio avisa que a taxa de marcação como spam do domínio disparou e suspende a conta. Os e-mails de matrícula e de boleto param de sair. Ninguém invadiu nada; alguém colou o e-mail da aluna no formulário e repetiu o envio em loop. A resposta curta: o "esqueci a senha" precisa de limite por endereço de e-mail e por IP, aplicado no servidor, com a mesma resposta na tela haja ou não envio, e o disparo do e-mail fora da requisição.

O limite protege três coisas ao mesmo tempo: a caixa de entrada da vítima, a reputação do seu domínio de envio e a sua conta no provedor de e-mail. E, bem feito, ele não revela se o endereço tem conta.

Por que esse formulário é um alvo

O formulário de recuperação é público, não pede login e faz o seu servidor enviar um e-mail para um endereço escolhido por quem preenche. Para quem quer incomodar alguém, é uma ferramenta pronta: o remetente é legítimo, passa nos filtros e chega na caixa principal. Para quem quer derrubar o seu envio, é ainda melhor: cada destinatário irritado que marca a mensagem como spam conta contra o seu domínio.

Há um segundo uso: enumeração. Se o formulário responde "enviamos" só para contas existentes, ou demora mais quando há envio, ele vira um verificador de cadastro. O post login que não entrega a lista de clientes trata dessa parte no login; aqui ela aparece de novo.

As regras do exemplo

Para a escola de idiomas, o desenho ficou assim:

  • Por endereço de e-mail: no máximo 1 envio a cada 2 minutos e 3 por hora. Vale mesmo para endereços sem conta, porque a chave do limite é o endereço digitado, não o usuário.
  • Por IP: no máximo 20 pedidos por hora. Acima disso, a resposta é 429 com Retry-After.
  • Global: um alerta quando o total de pedidos por hora passa do triplo da média da semana.

Os números são ponto de partida. Uma escola com 5 mil alunos raramente tem mais de dez pedidos legítimos por hora. Ajuste olhando o seu histórico, não o de outro sistema.

Como implementar passo a passo

  1. Normalize o e-mail (aparar espaços e passar para minúsculas) e calcule um hash dele para usar como chave. Assim o armazenamento de limites não guarda endereços em texto claro.
  2. Confira o limite por IP primeiro. Se estourou, responda 429. Isso não revela nada sobre contas.
  3. Confira os limites por endereço. Se estourou, não envie nada, mas responda exatamente o mesmo que responderia num envio normal.
  4. Responda antes de enviar. Coloque o pedido numa fila e devolva a resposta. O worker procura a conta, cria o link e envia. Assim o tempo de resposta não depende de a conta existir.
  5. Não mude nada na conta no pedido. A senha só muda quando a pessoa usa o link, como explica link de redefinição que expira e serve uma vez.
  6. Meça. Conte pedidos aceitos, pedidos limitados por e-mail e por IP. Um gráfico desses três números mostra um ataque em minutos.

Código em Express com Redis

const crypto = require('crypto');
const { createClient } = require('redis');
const fila = require('./fila'); // sua fila de tarefas

const redis = createClient({ url: process.env.REDIS_URL });
redis.on('error', (err) => console.error('redis', err));

async function contar(chave, janelaSegundos) {
  const n = await redis.incr(chave);
  if (n === 1) await redis.expire(chave, janelaSegundos);
  return n;
}

const RESPOSTA = {
  mensagem: 'Se houver uma conta com este e-mail, enviaremos as instruções em instantes.'
};

async function esqueciSenha(req, res, next) {
  try {
    const ip = req.ip;
    if (await contar('esqueci:ip:' + ip, 3600) > 20) {
      res.set('Retry-After', '3600');
      return res.status(429).json({ erro: 'Muitos pedidos deste endereço. Tente mais tarde.' });
    }

    const email = String(req.body.email || '').trim().toLowerCase();
    const chave = crypto.createHash('sha256').update(email).digest('hex');

    const recente = await redis.set('esqueci:pausa:' + chave, '1', { NX: true, EX: 120 });
    const naHora = await contar('esqueci:hora:' + chave, 3600);

    if (recente === 'OK' && naHora <= 3) {
      await fila.enviar('email-redefinicao', { email });
    } else {
      req.log.info({ chave, naHora }, 'esqueci-senha limitado');
    }
    return res.status(202).json(RESPOSTA);
  } catch (err) {
    return next(err);
  }
}

module.exports = { redis, esqueciSenha };

Conecte o cliente na inicialização com await redis.connect() antes de abrir o servidor HTTP. O SET com NX e EX só grava se a chave não existe e já define a expiração, o que dá a pausa de 2 minutos numa operação só. O contador por hora usa INCR e define a expiração no primeiro incremento.

O req.ip só reflete o IP real do visitante se o Express estiver configurado com trust proxy de acordo com o seu proxy reverso. Sem isso, atrás de um balanceador, todos os pedidos parecem vir do mesmo IP e o limite por IP bloqueia todo mundo junto. Com trust proxy aberto demais, o atacante forja o cabeçalho e escapa do limite.

O worker

O worker recebe o e-mail, procura a conta e, se ela existir, cria o pedido de redefinição e envia. Se não existir, não faz nada. Algumas equipes preferem enviar um aviso para endereços sem conta ("alguém pediu redefinição, mas não há conta com este e-mail"); se fizer isso, o mesmo limite por endereço já protege contra abuso.

A reputação do domínio também é sua

Separe o envio transacional do marketing, com subdomínio ou fluxo próprio no provedor, para que um problema num lado não derrube o outro. Confira que SPF, DKIM e DMARC estão corretos; o post formulário de contato que chega mostra como diagnosticar entrega. E acompanhe a taxa de reclamação no painel do provedor: um pico ali, junto com pico de pedidos de redefinição, é o padrão de abuso deste formulário.

Erros comuns

  • Limitar só no front, desabilitando o botão. O ataque não usa o botão; ele chama a rota diretamente.
  • Limitar só contas existentes. Endereços sem conta passam sem limite, e a diferença de comportamento revela quem tem cadastro.
  • Dizer "você já pediu, aguarde" só para quem tem conta. A mensagem de limite vira um verificador de cadastro.
  • Enviar o e-mail dentro da requisição. Pedidos para contas reais demoram mais, e o tempo denuncia a existência.
  • Ignorar o trust proxy. Ou todos dividem o mesmo limite de IP, ou ninguém tem limite nenhum.
  • Não monitorar o volume. Você descobre o ataque pelo e-mail suspenso do provedor, não pelo seu painel.

Checklist

  • Existe limite por endereço de e-mail, aplicado também a endereços sem conta.
  • Existe limite por IP, com 429 e Retry-After.
  • A resposta é a mesma frase e o mesmo status com ou sem envio, com ou sem limite por endereço.
  • O envio acontece numa fila, fora da requisição.
  • O pedido não altera a senha nem bloqueia a conta.
  • O IP do cliente é lido corretamente atrás do proxy.
  • Há métrica e alerta para o volume de pedidos.

Teste agora: quantos e-mails a sua caixa recebe se você clicar cem vezes seguidas em "esqueci a senha" com o seu próprio endereço?

Avalie este artigo

Referência: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html