Repository navigation
Description
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-leaderpassa 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_atmais antigo que uma janela de graça (ex. 15–20 s) — usando oupdated_atjá mantido pelo triggerrooms_touch. - Um
claim-leaderde umplayerIdarbitrário contra uma sala cujo líder acabou de agir é rejeitado (403/409), e oleader_idnã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.mjsverde.
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
caioross commentedon Jul 8, 2026
🔧 Resolvido em PR #12.
claim-leaderpassa a exigir estagnação da sala (updated_atdo triggerrooms_touchalém deLEADER_GRACE_MS=15s) + update condicional emleader_idanti-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.feat(multiplayer): moderação do líder — notifica a sala e permite kic…
caioross commentedon Jul 28, 2026
🗂️ Curador · 2026-07-28 — evidência nova:
claim-leadernão é o único caminhoInvestigando 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 deroomséfor select using (true)comgrant 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_idda sala e passa a enviá-lo comoplayerId. A autorização das ações leader-only é literalmente uma comparação com esse valor (route.ts:77):Ou seja,
settings(:83),start(:113),kick(:156) ereset(: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 declaim-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
GETde 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.