tva
← Artigos

Auditoria de segurança em múltiplas fases: reforçando uma aplicação Next.js

Auditorias de segurança em aplicações web tendem a encontrar as mesmas categorias de problemas independentemente do conjunto de tecnologias: lacunas de autorização, validação de entrada ausente, cabeçalhos HTTP de segurança inadequados e vulnerabilidades de temporização em fluxos de autenticação. A manifestação específica difere por framework, mas os padrões subjacentes são consistentes. O que varia é o rigor com que a auditoria é estruturada – uma revisão não estruturada provavelmente encontrará os problemas óbvios e deixará passar os sutis.

Conduzimos uma auditoria em múltiplas fases em uma aplicação Next.js em produção usando o Supabase como backend. A aplicação gerenciava autenticação de usuários, armazenava registros vinculados a usuários e expunha várias rotas de API usadas tanto pelo frontend quanto por webhooks externos. Nenhuma das descobertas foi catastrófica, mas várias eram o tipo de problema que se torna um incidente sério se descoberto por alguém com intenções erradas. Veja o que a auditoria cobriu, o que encontrou e o que mudou como resultado.

Fase um: autenticação e autorização

A primeira fase focou na camada de autenticação e nas políticas de segurança em nível de linha (Row Level Security, RLS) que governam o acesso a dados. Esses são os controles mais críticos em qualquer aplicação Supabase: se a autenticação não funciona corretamente ou a RLS está mal configurada, nenhuma quantidade de validação de entrada ou limitação de requisições compensa.

A auditoria começou com um inventário completo das tabelas do Supabase e seu status de RLS. Cada tabela em um esquema exposto deve ter a RLS habilitada. Com as permissões padrão do Supabase, qualquer tabela em um esquema exposto, como public, sem RLS habilitada é acessível para leitura e escrita por qualquer pessoa com a anon key – que é pública por design e incorporada no bundle do lado do cliente. Nesta aplicação, uma tabela tinha a RLS desabilitada. Era uma tabela de consulta tratada como dados de referência, e no momento em que foi criada, continha apenas valores estáticos. Com o tempo, algumas linhas foram adicionadas que incluíam metadados vinculados ao usuário que deveriam ter sido protegidos. A correção foi direta – habilitar a RLS e adicionar uma política – mas a falha de processo subjacente foi notável: novas tabelas devem ter a RLS habilitada por padrão, não como etapa retroativa.

As políticas de RLS nas tabelas protegidas foram então revisadas quanto à correção. Um erro comum é uma política que verifica corretamente o ID do usuário, mas o faz na coluna errada. Por exemplo, uma política em uma tabela documents que verifica auth.uid() = created_by restringe corretamente o acesso ao criador, mas se a aplicação também suporta documentos compartilhados, uma linha de colaborador em uma tabela de junção document_collaborators precisa de sua própria política – a verificação na tabela primária não é transitiva para dados associados por junções.

Uma política continha um erro lógico que sobreviveu à revisão de código: usava USING onde deveria ter usado tanto USING quanto WITH CHECK. A cláusula USING determina quais linhas existentes um comando pode ver, atualizar ou excluir; WITH CHECK determina quais linhas novas ou alteradas podem ser escritas. Quando uma política que abrange escritas (FOR ALL ou FOR UPDATE) omite WITH CHECK, o PostgreSQL reutiliza a expressão USING para as escritas. Assim, uma cláusula USING formulada como regra de leitura permitia que qualquer usuário autenticado escrevesse linhas que pudesse ler – o que não era o comportamento pretendido. Essa é uma sutileza da RLS do PostgreSQL que é fácil de ignorar.

Fase dois: validação de entrada

A segunda fase revisou cada ponto de entrada onde dados fornecidos pelo usuário entram no sistema: manipuladores de rotas de API em app/api/, server actions e envios de formulários. O objetivo era confirmar que todas as entradas são validadas antes de serem usadas, e que as falhas de validação produzem erros informativos em vez de exceções não tratadas.

A aplicação estava usando Zod para validação parcialmente, mas de forma inconsistente. Algumas rotas tinham esquemas completos com schema.parse() no topo do manipulador. Outras estavam lendo campos do req.body diretamente sem validação, dependendo de asserções de tipo TypeScript que não fornecem segurança em tempo de execução:

// Inseguro: asserção de tipo TypeScript, sem validação em tempo de execução
const { userId, documentId } = req.body as { userId: string; documentId: string };

// Seguro: parse do Zod, lança exceção em entrada inválida
const schema = z.object({
  userId: z.string().uuid(),
  documentId: z.string().uuid(),
});
const { userId, documentId } = schema.parse(req.body);

A correção foi adicionar esquemas Zod a cada rota de API não validada e estabelecer um middleware de validação compartilhado por onde todas as rotas passam. O padrão de middleware é importante: garante que a camada de validação não possa ser contornada por um caminho de código que foi adicionado sem conhecimento do requisito de validação.

Uma rota aceitava um parâmetro redirectUrl que era usado em uma chamada res.redirect(). Isso é uma vulnerabilidade de redirecionamento aberto (open redirect) – um atacante pode criar um link para a aplicação que redireciona usuários para um site de phishing após uma interação de aparência legítima. A correção é validar o destino do redirecionamento contra uma lista de permissões de caminhos aceitáveis:

const ALLOWED_REDIRECT_PATHS = ['/dashboard', '/settings', '/profile'];

const redirectPath = schema.parse(req.query).redirectUrl;
if (!ALLOWED_REDIRECT_PATHS.includes(redirectPath)) {
  return res.redirect('/dashboard');
}
return res.redirect(redirectPath);

Fase três: cabeçalhos HTTP de segurança e CSP

Os cabeçalhos da política de segurança de conteúdo (Content Security Policy, CSP) estavam completamente ausentes da aplicação. Sem uma CSP, uma injeção XSS bem-sucedida – seja por conteúdo gerado pelo usuário ou um script de terceiros comprometido – pode executar JavaScript arbitrário no contexto da aplicação, com acesso a tokens de autenticação e dados do usuário.

Adicionar uma CSP a uma aplicação Next.js requer cuidado porque o Next.js insere alguns scripts inline por padrão. A abordagem recomendada usa nonces – um valor gerado com aleatoriedade criptograficamente segura por requisição que é adicionado ao cabeçalho CSP e aplicado a cada script inline. O Next.js aplica o nonce aos próprios scripts lendo a política dos cabeçalhos da requisição, portanto o middleware de produção também precisa encaminhar a CSP (e normalmente um cabeçalho x-nonce) por meio de NextResponse.next({ request: { headers } }), como mostra o guia de CSP do Next.js; o trecho abaixo define apenas os cabeçalhos de resposta. Ele também importa o módulo crypto do Node, que o runtime Edge não fornece; nesse ambiente, use a API Web Crypto global (por exemplo, crypto.randomUUID()):

// middleware.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';

export function middleware(request: Request) {
  const nonce = crypto.randomBytes(16).toString('base64');
  const cspHeader = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' data: https:`,
    `connect-src 'self' https://api.example.com`,
    `frame-ancestors 'none'`,
  ].join('; ');

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', cspHeader);
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  return response;
}

A implantação inicial do CSP usou o modo Content-Security-Policy-Report-Only, que relata violações sem bloqueá-las. Isso detectou vários scripts inline legítimos e origens de terceiros que precisavam estar na lista de permissões antes de mudar para o modo de bloqueio. Executar em modo de somente relatório por uma semana antes de ativar o bloqueio é uma abordagem comum – ativar o bloqueio sem essa etapa normalmente quebra funcionalidades que não eram visíveis nos testes.

A auditoria também descobriu que o cabeçalho Strict-Transport-Security estava sendo definido pelo balanceador de carga, mas não pela aplicação. Isso é aceitável se o balanceador de carga for o único ponto de entrada, mas vale a pena confirmar explicitamente. Defesa em profundidade significa que a aplicação deve definir seus próprios cabeçalhos de segurança mesmo quando a infraestrutura os fornece.

Fase quatro: limitação de requisições

Os endpoints de autenticação – login, cadastro, redefinição de senha – não tinham limitação de requisições. Sem limitação de requisições, esses endpoints aceitam requisições ilimitadas, o que possibilita ataques de força bruta em senhas e enumeração de endereços de e-mail válidos através da diferença no comportamento das respostas.

A limitação de requisições em rotas de API Next.js pode ser implementada com um contador com backend Redis; o exemplo abaixo usa uma janela fixa, mais simples que uma janela deslizante. Como executa INCR e EXPIRE em chamadas separadas, uma falha entre elas pode deixar uma chave sem expiração. Por isso, o código de produção deve executar ambas as operações de forma atômica (por exemplo, em uma transação ou um script Lua). A chave é tipicamente o endereço IP ou, para endpoints autenticados, o ID do usuário. Para os endpoints de autenticação, o limite baseado em IP é apropriado porque o usuário ainda não está autenticado:

// lib/rateLimit.ts
import { Redis } from '@upstash/redis';

const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! });

export async function checkRateLimit(identifier: string, limit: number, windowSeconds: number) {
  const key = `rate_limit:${identifier}`;
  const count = await redis.incr(key);
  if (count === 1) {
    await redis.expire(key, windowSeconds);
  }
  return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}

Os limites aplicados foram conservadores: as tentativas de login são limitadas por IP por janela de quinze minutos, as solicitações de redefinição de senha são limitadas por endereço de e-mail por hora. Esses limites são permissivos o suficiente para que usuários legítimos raramente os encontrem em uso normal, e restritivos o suficiente para tornar inviáveis na prática os ataques automatizados de força bruta.

Fase cinco: proteção contra ataques de temporização

Os ataques de temporização em endpoints de autenticação permitem que um atacante infira se um endereço de e-mail está registrado medindo a diferença no tempo de resposta entre “e-mail não encontrado” e “e-mail encontrado, senha incorreta.” Uma consulta que encontra um usuário leva um pouco mais de tempo do que uma que não encontra, porque a comparação de senha (que usa bcrypt ou Argon2) só é executada quando o usuário é encontrado. Essa diferença – tipicamente na faixa de 50 a 200ms – é mensurável com requisições suficientes.

A mitigação padrão é executar a comparação de hash de senha incondicionalmente, usando um hash fictício quando o usuário não existe:

const DUMMY_HASH = 'b';

export async function verifyLogin(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email } });

  // Sempre executar a comparação bcrypt para normalizar o tempo de resposta
  const hashToCompare = user?.passwordHash ?? DUMMY_HASH;
  const isValid = await bcrypt.compare(password, hashToCompare);

  if (!user || !isValid) {
    return null;  // Mesmo caminho de código para ambos os casos de falha
  }
  return user;
}

O valor DUMMY_HASH acima é um marcador de posição: não é um hash bcrypt válido de 60 caracteres, e bibliotecas como bcryptjs retornam false imediatamente para hashes malformados, o que anularia a normalização de temporização. Na prática, gere um hash real uma única vez na inicialização (por exemplo, com bcrypt.hash() sobre uma string aleatória), usando o mesmo fator de custo dos hashes armazenados. O problema não envolvia o endpoint de login integrado do Supabase; apareceu em uma rota de autenticação customizada que a aplicação havia adicionado para lidar com uma integração legada. Qualquer lógica de autenticação personalizada que não usa as funções de autenticação do Supabase precisa implementar a normalização de temporização explicitamente.

O que mudou e o que não mudou

Das descobertas em todas as cinco fases, cada item foi abordado antes que a auditoria fosse considerada completa. A priorização foi clara: a configuração incorreta da RLS e o open redirect foram tratados como críticos e corrigidos imediatamente. A implantação do CSP e a limitação de requisições foram tratados como alta prioridade e implantados no mesmo sprint. A normalização de temporização na rota legada foi média prioridade e lançada no sprint seguinte.

A auditoria também produziu um conjunto de mudanças de processo: novas tabelas agora exigem uma revisão das políticas de RLS antes que o pull request possa ser mesclado, as rotas de API têm um wrapper compartilhado de validação Zod que faz parte do modelo de rota, e o cabeçalho CSP é agora gerado a partir de uma configuração centralizada em vez de strings inline.

O resultado mais relevante não foi nenhuma correção individual, mas o inventário. Entender exatamente quais dados cada tabela contém, qual política RLS a governa e quais rotas de API a acessam é a base para tomar decisões de segurança corretas à medida que a aplicação evolui. Sem esse inventário, a segurança é reativa – você corrige o que encontra. Com ele, a segurança se torna uma propriedade sobre a qual você pode raciocinar deliberadamente.

Artigos relacionados

Leitura complementar