"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
- 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.
- Confira o limite por IP primeiro. Se estourou, responda 429. Isso não revela nada sobre contas.
- Confira os limites por endereço. Se estourou, não envie nada, mas responda exatamente o mesmo que responderia num envio normal.
- 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.
- 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.
- 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?
Referência: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html