Skip to content

Anti-cheat/Multiplayer: kick é broadcast sem autoridade — qualquer jogador expulsa qualquer um (inclusive o líder) #39

Closed

Description

@caioross

O buraco

A ação de expulsar um jogador (kick) trafega inteiramente por broadcast peer-to-peer, sem nenhuma verificação de servidor. Qualquer cliente conectado ao canal da sala pode expulsar qualquer jogador — inclusive o próprio líder.

  • Emissor (src/lib/useRoom.ts:432-436): kick(targetId) faz ch.send({ type: "broadcast", event: "kick", payload: { id: targetId } }). Nenhum check de isLeader, nenhuma passagem pela API.
  • Receptor (src/lib/useRoom.ts:195-204): ao receber event:"kick" com payload.id === meuId, o cliente sai da sala e limpa a sessão — confiando cegamente que o broadcast veio do líder.
  • A UI só expõe o botão ao líder (src/components/RoomView.tsx:102, src/components/PlayerList.tsx:50), mas isso é cosmético: o canal Realtime aceita channel.send de qualquer assinante (broadcast: { self: false }), então a proteção existe só no botão, não no transporte.

Vetor concreto (reproduzível hoje, em produção)

  1. Entrar em qualquer sala (código público via GET /api/rooms, ou entrar numa sala compartilhada).
  2. No console: supabase.channel("coderacer:room:<CODE>").send({ type:"broadcast", event:"kick", payload:{ id:"<idDaVítima>" } }).
  3. A vítima — inclusive o líder — é expulsa e perde a sessão. Repetindo em loop, esvazia a sala inteira (griefing).

Os ids das vítimas são triviais de obter: já circulam nos broadcasts de progress/ready/chat e no presenceState, todos visíveis a qualquer assinante do canal.

Por que não é duplicata de #6

#6 é sequestro de liderança via claim-leader (assumir o controle). Esta é expulsão não-autorizada via kick (negar acesso aos outros). Vetores, ações e superfícies distintas — #6 toca claim-leader na rota; esta toca o broadcast kick, que hoje nem passa pela rota. As duas juntas mostram o padrão comum: ações privilegiadas de sala confiam no cliente.

Acceptance criteria

  • Invariante: um broadcast kick emitido por um não-líder NÃO expulsa ninguém. Só o líder legítimo da sala (verdade = room.leader_id) consegue remover um jogador.
  • A remoção deixa de depender de um broadcast confiável cegamente — a vítima só sai após confirmar com o servidor que foi de fato removida pelo líder.
  • kick de um não-líder na rota retorna 403 (mesmo padrão de leaderOnly() já usado em settings/start/reset, src/app/api/rooms/[code]/route.ts:38-40).
  • Cobertura em .claude/skills/cr-multiplayer/scripts/validate-persistence.mjs (verde): líder remove jogador → autorizado; não-líder tenta remover → rejeitado (403), estado da sala inalterado; alvo inexistente → no-op seguro.
  • Fluxo honesto de sala (líder expulsa um jogador de fato) continua funcionando de ponta a ponta.

Dica de abordagem

Reaproveitar a autoridade que já existe no servidor em vez de inventar cripto no cliente:

  • Adicionar ação kick em POST /api/rooms/[code] com if (!isLeader) return leaderOnly(), e registrar o alvo removido no estado durável da sala (ex.: coluna aditiva kicked_ids text[] via migração aditiva idempotente em supabase/migrations/, ou reusar um campo existente do row).
  • A vítima descobre a remoção pela subscription postgres_changes da tabela rooms que o useRoom já mantém (useRoom.ts:206-213): ao ver o próprio id em kicked_ids, sai — sem confiar em broadcast algum.
  • O broadcast kick pode continuar como hint de baixa latência (UX), mas nunca como a única prova: a saída só é definitiva após a confirmação do servidor.
  • Manter a lógica de autorização testável (função pura recebendo room + playerId), no espírito de sanitizeResults/validateFinishTiming.

Área de quórum

Toca src/app/api/rooms/** + migração aditiva idempotente → HANDBOOK §7.2: o PR sai non-draft com a linha exata Solicito quórum (HANDBOOK §7). A migração é só arquivo (o dono aplica; nunca pnpm db:migrate).

Activity

  1. added
    P2Prioridade normal
    area:multiplayerSalas, Realtime, presença, leaderboard, persistência
    area:securityRLS, service_role, validação de entrada, segredos
    on Jul 16, 2026
  2. caioross commented on Jul 17, 2026

    @caioross
    OwnerAuthor

    🏛️ Parecer do Conselho · 2026-07-17

    Veredito: APROVADA PARA EXECUÇÃO (com escopo honesto — ver Segurança: mitiga, não elimina)

    • Engenharia: confirmado. Emissor useRoom.ts:432-436 faz ch.send de kick sem check de isLeader e sem passar pela rota. Receptor useRoom.ts:195-204 obedece cegamente: ao ver o próprio id, chama fail()+onLeaveRef.current() (l.201-202) apagando a sessão. Não há lista durável de jogadores (players vivem em presence efêmera) → o servidor não conhece membros; o que importa é a autoridade da escrita. A abordagem da issue (ação kick na rota + coluna aditiva + subscription já existente) está correta e reusa o que há: a rota já tem leaderOnly() (route.ts:36-37, keyed em room.leader_id === playerId) usado em settings/start/reset.
    • Segurança/Anti-cheat (lente crítica):
      • Mudança obrigatória e fácil de esquecer: o blind-obey em useRoom.ts:197-203 precisa deixar de expulsar por broadcast. Se continuar chamando fail()/onLeave() ao receber o broadcast, o vetor permanece 100% aberto mesmo com a rota nova. O broadcast pode virar só hint de UI, mas nunca disparar a saída — a saída só via kicked_ids do servidor (AC#2). Sem isso o PR "fecha" a issue sem fechar o buraco.
      • Escopo honesto (não prometer demais): leaderOnly() confia num playerId vindo do corpo (route.ts:29), e o leader_id circula em presence/broadcasts para qualquer assinante. Um atacante copia o leader_id e chama a rota kick com playerId = leader_id → é tratado como líder. Este PR elimina o kick anônimo trivial de 1 linha (ganho real), mas não dá autoridade forte — a fechadura definitiva é a mesma de Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6 (token/segredo por jogador, hoje decisao-dono). O corpo do PR deve dizer "mitiga: passa a exigir spoof do leader_id", não "elimina". Linkar Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6; não reabrir a decisão aqui.
      • Ban é soft por natureza: o id é client-generated (newPlayerId, em sessionStorage por sala) — o griefer limpa o storage e volta com novo id. kicked_ids é dissuasor, não banimento; registrar como limitação. Definir se reset/nova partida limpa kicked_ids (recomendo sim, para não vazar estado entre partidas).
    • UX/Game-feel: fluxo honesto (líder expulsa) segue via botão (RoomView.tsx:102/PlayerList.tsx:50), agora chamando a rota. Manter o broadcast como hint de latência (some instantâneo da lista) é ok, desde que a saída real seja confirmada pelo servidor.
    • Performance: desprezível; a subscription postgres_changes de rooms já existe (useRoom.ts:206-213). Sem impacto na área sagrada.
    • Produto: P2 correta; credibilidade do multiplayer. Mesmo padrão de "ações privilegiadas confiam no cliente" de Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6/Anti-cheat: finish sem coerência temporal — leaderboard forjável em 1 request e corrida alheia encerrável #34.

    Plano sugerido:

    1. Migração aditiva idempotente (confira o nº do último mergeado — o working tree só tem 0001–0003; a 0004 de fix(security): allowlist de language/difficulty nas rotas de sala (#35) #38 ainda não mergeou): alter table rooms add column if not exists kicked_ids text[] not null default '{}'; — só arquivo, o dono aplica.
    2. RoomRow (room.ts:15-28): kicked_ids: string[].
    3. route.ts case "kick": if (!isLeader) return leaderOnly(); valida targetId:string; update({ kicked_ids }) com dedupe; alvo ausente/já presente → no-op idempotente 200.
    4. useRoom.ts: kick() (l.432-436) chama postAction("kick", { targetId }) (não ch.send). No handler postgres_changes (l.206-213): se next.kicked_ids?.includes(id) → sessionStorage.removeItem + fail("removido pelo líder") + onLeave. Remover a expulsão por broadcast em l.197-203.
    5. Cobertura em .claude/skills/cr-multiplayer/scripts/validate-persistence.mjs: função pura de autorização (room+playerId+targetId → permitido?), líder→autoriza, não-líder→403, alvo inexistente→no-op, estado inalterado.
    6. PR non-draft §7.2 com a linha exata Solicito quórum (HANDBOOK §7) (toca api/rooms/** + migração). Corpo com escopo honesto (mitiga, não elimina; linka Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6).
  3. added
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    and removed
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    on Jul 18, 2026
  4. caioross commented on Jul 18, 2026

    @caioross
    OwnerAuthor

    Resolvida em PR #44 (non-draft, §7.2 quórum).

    Abordagem (plano do Conselho): a autoridade da expulsão foi do broadcast peer-to-peer para o servidor. POST /api/rooms/[code] ganha a ação kick leader-only (403 via leaderOnly()), que registra o alvo em rooms.kicked_ids (coluna aditiva, migração 0004, dono aplica). A vítima só sai ao ver o próprio id em kicked_ids pela subscription postgres_changes; o handler de broadcast kick (blind-obey) foi removido — era o vetor. reset limpa kicked_ids. Fronteira pura canKick/addKickedId coberta por validate-persistence.mjs (raiz + skill).

    Gate: typecheck ✅ · build ✅ · validate-persistence 46/0 (raiz) e 35/0 (skill) ✅ · validate-metrics 27/0 ✅.

    Escopo honesto: mitiga o kick anônimo trivial; não elimina o spoof de leader_id (fechadura forte = #6). Detalhes e ordem de deploy (migração antes) no corpo da PR.

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

    P2Prioridade normalarea:multiplayerSalas, Realtime, presença, leaderboard, persistênciaarea:securityRLS, service_role, validação de entrada, segredos

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions