Uma Auditoria de Segurança em Múltiplas Fases: Fortalecendo uma Aplicação Next.js
Auditorias de segurança em aplicações web tendem a encontrar as mesmas categorias de problemas independentemente da stack: lacunas de autorização, validação de entrada ausente, headers 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 é a sistematicidade com que a auditoria é estruturada – uma revisão não estruturada provavelmente encontrará os problemas óbvios e perderá 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 front-end 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 Row Level Security que governam o acesso a dados. Esses são os controles mais críticos em qualquer aplicação Supabase: se a autenticação está quebrada ou o RLS está mal configurado, nenhuma quantidade de validação de entrada ou rate limiting compensa.
A auditoria começou com um inventário completo das tabelas do Supabase e seu status de RLS. Cada tabela deve ter o RLS habilitado. Qualquer tabela sem RLS habilitado é legível para qualquer um com a anon key – que é pública por design e incorporada no bundle do lado do cliente. Nesta aplicação, uma tabela tinha o RLS desabilitado. Era uma tabela de lookup 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 o RLS e adicionar uma política – mas a falha de processo subjacente foi notável: novas tabelas devem ter o RLS habilitado 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 unidos.
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 governa o acesso de leitura; WITH CHECK governa o acesso de escrita. Uma política com apenas USING permite que qualquer usuário autenticado escreva na tabela enquanto puder ler dela – o que não era o comportamento pretendido. Essa é uma sutileza do 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 z.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 remediaçã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 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: Headers HTTP de Segurança e CSP
Os headers de Content Security Policy estavam completamente ausentes da aplicação. Sem um 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 um 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 aleatório criptograficamente gerado por requisição que é adicionado ao header CSP e aplicado a cada script inline:
// 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;
}
O deployment 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 aplicação. Rodar em modo de somente relatório por uma semana antes de aplicar é a abordagem padrão – aplicar sem essa etapa normalmente quebra funcionalidades que não eram visíveis nos testes.
A auditoria também descobriu que o header 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 headers de segurança mesmo quando a infraestrutura os fornece.
Fase Quatro: Rate Limiting
Os endpoints de autenticação – login, cadastro, redefinição de senha – não tinham rate limiting. Sem rate limiting, 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.
O rate limiting em rotas de API Next.js pode ser implementado com um contador de janela deslizante com backend Redis. 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 nunca os encontrem em uso normal, e restritivos o suficiente para que ataques automatizados não sejam viáveis.
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ó roda 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;
}
A autenticação integrada do Supabase lida com isso corretamente para seu próprio endpoint de login. O problema 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 do RLS e o open redirect foram tratados como críticos e corrigidos imediatamente. O deployment do CSP e o rate limiting 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 de política RLS antes que o pull request possa ser mesclado, as rotas de API têm um wrapper de validação Zod compartilhado que faz parte do template da rota, e o header CSP é agora gerado a partir de uma configuração centralizada em vez de strings inline.
O resultado mais saliente 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.
Insights Relacionados
- Recuperação de Desastres para Serviços Auto-Hospedados – A camada de backup e recuperação que complementa o fortalecimento de segurança – o que acontece quando os controles falham apesar dos melhores esforços.
- Criando um Aplicativo de Preparação para Exames com Milhares de Perguntas – Políticas RLS do Supabase em uma aplicação de produção, incluindo o padrão de acesso multilocatário.