Repository navigation
Description
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)fazch.send({ type: "broadcast", event: "kick", payload: { id: targetId } }). Nenhum check deisLeader, nenhuma passagem pela API. - Receptor (
src/lib/useRoom.ts:195-204): ao receberevent:"kick"compayload.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 aceitachannel.sendde 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)
- Entrar em qualquer sala (código público via
GET /api/rooms, ou entrar numa sala compartilhada). - No console:
supabase.channel("coderacer:room:<CODE>").send({ type:"broadcast", event:"kick", payload:{ id:"<idDaVítima>" } }). - 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
kickemitido 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.
-
kickde um não-líder na rota retorna 403 (mesmo padrão deleaderOnly()já usado emsettings/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
kickemPOST /api/rooms/[code]comif (!isLeader) return leaderOnly(), e registrar o alvo removido no estado durável da sala (ex.: coluna aditivakicked_ids text[]via migração aditiva idempotente emsupabase/migrations/, ou reusar um campo existente do row). - A vítima descobre a remoção pela subscription
postgres_changesda tabelaroomsque ouseRoomjá mantém (useRoom.ts:206-213): ao ver o próprio id emkicked_ids, sai — sem confiar em broadcast algum. - O broadcast
kickpode 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 desanitizeResults/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
caioross commentedon Jul 17, 2026
🏛️ Parecer do Conselho · 2026-07-17
Veredito: APROVADA PARA EXECUÇÃO (com escopo honesto — ver Segurança: mitiga, não elimina)
useRoom.ts:432-436fazch.senddekicksem check deisLeadere sem passar pela rota. ReceptoruseRoom.ts:195-204obedece cegamente: ao ver o próprio id, chamafail()+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çãokickna rota + coluna aditiva + subscription já existente) está correta e reusa o que há: a rota já temleaderOnly()(route.ts:36-37, keyed emroom.leader_id === playerId) usado emsettings/start/reset.useRoom.ts:197-203precisa deixar de expulsar por broadcast. Se continuar chamandofail()/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ó viakicked_idsdo servidor (AC#2). Sem isso o PR "fecha" a issue sem fechar o buraco.leaderOnly()confia numplayerIdvindo do corpo (route.ts:29), e oleader_idcircula em presence/broadcasts para qualquer assinante. Um atacante copia oleader_ide chama a rotakickcomplayerId = 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, hojedecisao-dono). O corpo do PR deve dizer "mitiga: passa a exigir spoof doleader_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.newPlayerId, emsessionStoragepor sala) — o griefer limpa o storage e volta com novo id.kicked_idsé dissuasor, não banimento; registrar como limitação. Definir sereset/nova partida limpakicked_ids(recomendo sim, para não vazar estado entre partidas).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.postgres_changesderoomsjá existe (useRoom.ts:206-213). Sem impacto na área sagrada.Plano sugerido:
0001–0003; a0004de 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.RoomRow(room.ts:15-28):kicked_ids: string[].route.tscase "kick":if (!isLeader) return leaderOnly();validatargetId:string;update({ kicked_ids })com dedupe; alvo ausente/já presente → no-op idempotente 200.useRoom.ts:kick()(l.432-436) chamapostAction("kick", { targetId })(nãoch.send). No handlerpostgres_changes(l.206-213): senext.kicked_ids?.includes(id)→sessionStorage.removeItem+fail("removido pelo líder")+onLeave. Remover a expulsão por broadcast em l.197-203..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.Solicito quórum (HANDBOOK §7)(tocaapi/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).caioross commentedon Jul 18, 2026
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çãokickleader-only (403 vialeaderOnly()), que registra o alvo emrooms.kicked_ids(coluna aditiva, migração0004, dono aplica). A vítima só sai ao ver o próprio id emkicked_idspela subscription postgres_changes; o handler de broadcastkick(blind-obey) foi removido — era o vetor.resetlimpakicked_ids. Fronteira puracanKick/addKickedIdcoberta porvalidate-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.fix(rooms): reset não depende de kicked_ids e kick falha alto sem a m…
fix(security): teto em kicked_ids — fecha a inflação da linha da sala (…
fix(security): broadcast de sala vira sinal, não estado autoritativo (#…
feat(multiplayer): sinal de sala por broadcast + releitura server-side (