Quando o layout não protege: a falha de autorização que um ?. teria aberto
Depois de um deploy de rotina no meu SaaS de QR codes, rodei a checagem de sempre: acessar as rotas principais e ler o log do servidor. Tudo respondia certo. Mas no fim do log tinha uma linha que não deveria estar lá:
⨯ TypeError: Cannot read properties of null (reading 'user')
digest: '2933376468'
O digest é como o Next.js marca um erro que aconteceu no servidor durante a renderização. Nenhuma página tinha quebrado para o usuário — então era fácil tratar como ruído. Resolvi descobrir de onde vinha.
Isolando a rota
Acessei cada rota do dashboard sem estar logado e contei as linhas de erro no log antes e depois de cada acesso. Só uma disparava: a listagem, /dashboard. E ela respondia exatamente o que devia — um redirecionamento (307) para a tela de login. O visitante sem login era mandado embora corretamente. Mesmo assim, algo no servidor estourava no caminho.
A causa: o layout não é uma barreira
O dashboard tinha a conferência de login no layout, o componente que envolve todas as páginas daquela seção:
// layout do dashboard
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login");
}
E a página da listagem simplesmente confiava nisso:
// página da listagem
const session = await getServerSession(authOptions);
const qrCodes = await prisma.qrCode.findMany({
where: { userId: session!.user.id },
});
Parece razoável: se o layout já barra quem não está logado, a página nunca vai rodar sem sessão. Só que no App Router do Next.js o layout e a página renderizam em paralelo. O redirect() do layout não impede a página de começar a executar — ela roda assim mesmo, com a sessão vazia, e estoura ao tentar ler .user de null. O redirecionamento vence no final, por isso o usuário nunca via nada. Mas a página tinha rodado.
E o ! em session! não é uma conferência. É só uma promessa feita ao TypeScript de que aquele valor nunca será vazio. O compilador acredita e para de reclamar; em tempo de execução, nada é verificado.
Por que não vazou nada
O erro acontecia na leitura de session!.user.id, antes da consulta ao banco. Sem sessão, a página quebrava antes de buscar qualquer dado. Ou seja: estava seguro — mas por acaso, não por design.
A armadilha: a correção óbvia
Aqui está a parte que me fez parar. A forma mais natural de “consertar” esse erro, para quem só quer que ele suma do log, é trocar o ! por encadeamento opcional:
where: { userId: session?.user?.id }
O erro desaparece. E a falha aparece.
Sem sessão, session?.user?.id vale undefined. No Prisma, um campo com valor undefined dentro do where é ignorado — como se o filtro nem existisse. A consulta vira, na prática, findMany({}): todos os QR codes de todos os usuários. Não é bug do Prisma, é a semântica documentada (undefined significa “não filtrar por isso”; filtrar por vazio exige null explícito). Mas combinado com uma conferência de login que não protege o que parece proteger, transforma um erro de log inofensivo numa quebra de controle de acesso — a categoria número 1 do OWASP Top 10.
Neste caso específico o redirecionamento do layout ainda chegaria antes da resposta ir para o navegador, então o dado provavelmente não sairia na tela. Mas “provavelmente” é exatamente o tipo de proteção em que eu não quero confiar, e a página de detalhe tinha o mesmo padrão com uma diferença pior: consultava o banco antes de comparar o dono do registro.
A correção de verdade
Cada página passou a conferir a sessão por conta própria, no começo, antes de qualquer consulta — e o id do usuário vai para uma variável que só é usada depois de conferida:
const session = await getServerSession(authOptions);
// Nunca session?.user?.id direto no where: com undefined o
// Prisma ignora o filtro e devolveria os registros de todo mundo.
const userId = session?.user?.id;
if (!userId) redirect("/login");
const qrCodes = await prisma.qrCode.findMany({ where: { userId } });
O redirect() interrompe a execução, então da linha seguinte em diante o userId é garantidamente uma string — o próprio TypeScript entende isso. O comentário ficou no código de propósito, para ninguém “simplificar” de volta para o ?. no futuro.
Depois do deploy, repeti o mesmo teste: todas as rotas do dashboard, sem login, redirecionando para /login e o log do servidor limpo.
A lição
Três coisas que levo disso:
- Autorização mora onde o dado é acessado, não num componente que envolve a página. Um layout, um middleware ou um guard de rota são camadas úteis, mas a consulta que devolve dados precisa garantir, ela mesma, que tem um usuário válido — especialmente quando o framework executa as partes em paralelo.
!e?.não são verificações. Um silencia o compilador, o outro silencia o erro. Nenhum dos dois decide o que deve acontecer quando o valor está vazio — e é exatamente aí que mora a falha.- Ruído no log merece investigação. Essa linha de erro não afetava nenhum usuário e seria fácil de ignorar. Ela era o único sinal visível de que a proteção do dashboard não funcionava como eu achava.