02Segurança5 min

Por que faço os acessos de convidado com token de sessão e não com códigos

Um código de seis dígitos parece a forma simples de deixar alguém entrar sem conta. É, até você ter que revogá-lo, e então descobre que não sabe quem está expulsando.

Por Salva Castells

Imagem de capa: Jessica, CC BY-SA 2.0.
Neste post

Alguém quer mostrar um projeto para um cliente que não tem conta. A solução óbvia é um código curto: seis dígitos, expira em cinco minutos, o convidado digita e entra. Implementei assim numa ferramenta de notas e arquivos compartilhados, e funcionou desde o primeiro dia. O problema não foi não funcionar. Foi que três meses depois tive que refazer aquilo duas vezes.

Três iterações, e a terceira é a que deveria ter sido a primeira.

O que um código curto faz e o que não faz

A primeira tabela era literalmente isto:

CREATE TABLE guest_access_codes (
  id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
  user_id text NOT NULL,
  code char(6) NOT NULL,
  permission permission NOT NULL,
  session_duration_minutes integer NOT NULL,
  code_expires_at timestamptz NOT NULL,
  is_used boolean DEFAULT false NOT NULL,
  used_at timestamptz,
  session_expires_at timestamptz
);

Dois vencimentos distintos, e isso esteve certo desde o início: code_expires_at é quanto o código demora a morrer se ninguém usar (cinco minutos), e session_duration_minutes é quanto dura a visita depois de entrar. São coisas diferentes e misturá-las é um erro clássico. Um código que vive oito horas no WhatsApp é um buraco; uma sessão que corta em cinco minutos é inutilizável.

Com isso você já tem permissões (read ou read_write), uma tabela ponte para decidir a quais projetos o código dá acesso, e expiração. Parece completo. Não está: o código não identifica ninguém. Identifica o convite, não o convidado.

Primeira tentativa: passar o id da linha

A primeira correção foi parar de mandar o código de seis dígitos em cada requisição e mandar o id da linha. O cliente guardava um codeId, o backend o resolvia, e as requisições iam com Authorization: Bearer <codeId>.

Isso conserta uma coisa real: o código de seis dígitos para de viajar. Não está mais num log de acesso nem numa URL. Mas introduz algo pior. O codeId é um inteiro autoincremental. É adivinhável. É enumerável. Qualquer um com um id válido pode testar o de ao lado e, se aquela sessão continuar viva, entra. Troquei um segredo curto por um identificador que não é segredo nenhum.

E continua sem resolver o problema original, que era este: se duas pessoas usam o mesmo convite, compartilham o mesmo codeId. Você não pode expulsar uma sem expulsar a outra. Não pode saber qual das duas apagou o arquivo.

O que o token de sessão resolve

A terceira versão separa as duas coisas que passaram três meses juntas. O código de seis dígitos é um resgate: usa-se uma vez, no endpoint de conexão, e nunca mais. O que sai dali é um token de sessão:

const suffix = randomBytes(4).toString("hex");
const sessionToken = `${code}-${suffix}`;

await db
  .update(guestAccessCodes)
  .set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
  .where(eq(guestAccessCodes.id, record.id));

A resposta não devolve mais codeId, devolve sessionToken. A partir daí todo o frontend o carrega: onde havia guestCodeId passou a haver guestSessionToken, e o !!guestCodeId que decidia se alguém era convidado virou !!guestSessionToken.

O que muda de verdade:

  • Revogar. Colocar o sessionToken como null numa linha expulsa aquela sessão e só aquela. Com o código você não tinha para quem apontar.
  • Auditar. O token está na linha, então cada ação pode ser atribuída a um resgate concreto e a um horário concreto.
  • Não adivinhável. Já não é um inteiro sequencial. Quatro bytes aleatórios em hexadecimal não se enumeram.
  • Um único lugar onde expira. O sessionExpiresAt é escrito no mesmo update que emite o token. Não há dois relógios que possam dessincronizar.

O que a auditoria encontrou, e eu não

Meses depois passei uma auditoria no código. Saiu de tudo — uma rota de exportação para PDF sem nenhum guard de autenticação é o pior da lista — mas a que mais me fez pensar fui eu mesmo que anotei no TODO: mesmo que o código de seis seja consumido, o que ele devolve é um token de seis mais quatro, e é esse que autentica.

Aí está a falha. O código curto está protegido pela vida de cinco minutos e pelo fato de só poder ser usado uma vez. O token de sessão não tem nenhuma dessas duas coisas: dura horas e é usado em cada requisição. Trocar código por token tira um vetor e abre outro, e você só percebe se olhar depois com a cabeça fria. Quatro bytes bastam contra enumeração casual, não contra alguém com oito horas e um loop.

A outra coisa que apareceu, e que é de manual, é que o endpoint de conexão não envolvia nada numa transação. Entre ler que o código não está usado e marcá-lo como usado há uma janela. Duas requisições simultâneas com o mesmo código passavam as duas.

Banir por IP é mais frágil do que parece

O contador de tentativas falhas precisa saber de qual IP vem a requisição, e é aí que quase todo mundo erra igual. Em outro projeto eu tinha banimento permanente por IP e pegava o primeiro valor de x-forwarded-for. Esse header é uma lista cliente, proxy1, proxy2: cada proxy acrescenta à direita, mas ninguém limpa o que já vinha escrito. Pegar o primeiro é pegar algo que o cliente pode inventar.

Mudei para contar a partir do final, conforme quantos proxies de confiança há na frente:

const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map(s => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];

Com um proxy na frente, o último valor é o que o proxy escreve e o cliente não pode tocar. Se você pega o primeiro, qualquer um escapa do banimento mandando lixo, ou pior, bane outra pessoa.

O código de seis dígitos não era a parte difícil. A parte difícil era aceitar que um convite e uma sessão são dois objetos distintos, e que misturá-los deixa você sem poder revogar, sem poder auditar e com um identificador sequencial fazendo as vezes de credencial.