Skip to content

Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6

Open

Description

@caioross

Problema — sequestro de liderança de sala (broken access control)

A ação claim-leader em src/app/api/rooms/[code]/route.ts:89-94 não tem nenhuma verificação de autorização:

case "claim-leader": {
  if (!playerId) return NextResponse.json({ ok: false, error: "playerId" }, { status: 400 });
  // The client only claims when it has detected the current leader left.
  await sb.from("rooms").update({ leader_id: playerId }).eq("code", code);
  return NextResponse.json({ ok: true });
}

O comentário admite que o servidor confia no cliente. Mas o fluxo legítimo (eleição por presença ao líder sair) vive só no Realtime, no cliente — ver src/lib/useRoom.ts:339-351. Como a API roda com a service_role (bypass de RLS), ela é o único guardião — e aqui não guarda nada.

Vetor de ataque

Códigos de sala pública são descobríveis via GET /api/rooms. Um atacante faz um único POST /api/rooms/<code> com { "action": "claim-leader", "playerId": "<qualquer>" } e vira líder de qualquer sala ao vivo, mesmo com o líder legítimo presente. Como líder, ele então usa as ações leader-gated (settings, start, reset) para trollar: trocar a linguagem, forçar o start antes da hora, ou resetar a partida em andamento.

Impacto

  • Qualquer partida pública pode ser sabotada por um terceiro não participante.
  • Quebra a premissa de confiança de todas as ações isLeader — a checagem de líder é forte, mas a posse de líder é forjável.

Acceptance criteria (verificáveis)

  • claim-leader passa a exigir uma condição server-side que impeça a tomada com o líder ainda ativo. Sem sinal de presença no servidor, uma abordagem aceitável: só permitir o claim se a sala estiver estagnada — ex.: updated_at mais antigo que uma janela de graça (ex. 15–20 s) — usando o updated_at já mantido pelo trigger rooms_touch.
  • Um claim-leader de um playerId arbitrário contra uma sala cujo líder acabou de agir é rejeitado (403/409), e o leader_id não muda.
  • O fluxo legítimo (líder sai → membro presente mais antigo assume) continua funcionando: teste manual com duas abas confirma a migração de liderança quando o líder fecha a aba.
  • node scripts/validate-persistence.mjs verde.

Dica de abordagem

Adicionar a guarda no case "claim-leader": reler a sala, e só aplicar o update se now() - room.updated_at > GRACE_MS (ou condição equivalente que exija ausência do líder). Considerar tornar o update condicional (.eq("leader_id", room.leader_id)) para evitar corrida entre dois candidatos. Alternativa a debater com o Conselho: heartbeat leve do líder (leader_seen_at). Não exigir verificação de presença completa no servidor — ela não existe fora do Realtime.

Escopo/autonomia

Área de quórum (HANDBOOK §7.2 — API de salas + anti-cheat). Resolvível por agente com 3× APROVA no PR Doctor; não é núcleo irredutível.

Activity

  1. added
    P1Alto valor — próximo da fila
    area:multiplayerSalas, Realtime, presença, leaderboard, persistência
    area:securityRLS, service_role, validação de entrada, segredos
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    and removed
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    on Jul 7, 2026
  2. caioross commented on Jul 8, 2026

    @caioross
    OwnerAuthor

    🔧 Resolvido em PR #12.

    claim-leader passa a exigir estagnação da sala (updated_at do trigger rooms_touch além de LEADER_GRACE_MS=15s) + update condicional em leader_id anti-corrida. Cliente reestenta a eleição legítima em silêncio para não travar durante a janela. Sem migration. Gate: typecheck ✓, build ✓, validate-persistence 22/22 ✓. Área de quórum (§7.2) — quórum solicitado no PR.

  3. caioross commented on Jul 28, 2026

    @caioross
    OwnerAuthor

    🗂️ Curador · 2026-07-28 — evidência nova: claim-leader não é o único caminho

    Investigando a superfície de leitura de rooms (chapéu de segurança de hoje → #103) apareceu um dado que muda o custo-benefício da decisão parada em #19. Não altero labels nem escopo desta issue (decisao-dono) — deixo só o fato.

    O leader_id é público. A policy de rooms é for select using (true) com grant select ... to anon (0002_realtime_rooms.sql:26-29), e a anon key está no bundle de produção — confirmei nesta rodada no chunk /_next/static/chunks/643-351fe9e793afe957.js (URL do projeto + JWT de 208 chars). A anon key ser pública está certo; o problema é ela ler a tabela inteira.

    Consequência para esta issue: um atacante não precisa reivindicar liderança. Ele lê o leader_id da sala e passa a enviá-lo como playerId. A autorização das ações leader-only é literalmente uma comparação com esse valor (route.ts:77):

    const isLeader = room.leader_id === playerId;

    Ou seja, settings (:83), start (:113), kick (:156) e reset (:189) autorizam com base num identificador que qualquer pessoa consegue ler. leader_id é usado como se fosse segredo, mas é dado público.

    O que isso significa para a decisão #19: a mitigação por estagnação da PR #12 fecha o caminho claim-leader, e isso continua valendo — mas não fecha a impersonação direta, porque não depende de claim-leader. As duas opções da thread deixam de ser equivalentes em cobertura: só a fechadura de identidade (segredo por jogador que nunca vai à superfície legível por anon) cobre os dois caminhos.

    Honestidade sobre o método: não executei o GET de enumeração contra o banco de produção — agente não faz operação direta em produção (§7.1). A cadeia está provada por leitura de código e pela chave presente no bundle; a confirmação empírica é um request de fora, sem credencial privilegiada, e está descrita em #103.

    Não estou pedindo decisão nova — a #19 já está na sua mesa há 20 dias. Só garantindo que ela seja decidida com este fato à vista.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Alto valor — próximo da filaarea:multiplayerSalas, Realtime, presença, leaderboard, persistênciaarea:securityRLS, service_role, validação de entrada, segredosdecisao-donoAguarda decisão humana — agentes não tocam

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions