Skip to content

Segurança/Multiplayer: settings e kick ficaram fora da doutrina da #131 — TOCTOU grava a partida no bucket errado do ranking (route.ts:142-146, :226-235) #134

Open

Description

@caioross

O problema — a #131 fechou duas das cinco portas

A #131 estabeleceu a doutrina certa para as
transições de sala: a guarda vai dentro do UPDATE, não numa leitura anterior — quem decide se
a transição vale é o banco. O padrão ficou pronto e testado (RoomUpdateSpec + o parâmetro
guard de applyRoomUpdate), mas foi aplicado a duas actions. Levantando a tabela inteira
em origin/main@eee4b62:

action colunas escritas guarda na escrita checagem só lida
settings language, difficulty, max_players ❌ nenhuma (route.ts:142-146) room.status !== "lobby" (route.ts:123-124)
start status, snippet, start_at, results ✅ .match({status:"lobby"}) —
finish status, results ✅ .eq("status","racing") —
kick kicked_ids ❌ nenhuma (route.ts:226-235) —
reset status, start_at, results ✅ .match({status:"finished"}) —
claim-leader leader_id ❌ (é a #6, decisao-dono — não tocar) —

Sobraram exatamente as duas que dá para fechar sem depender de decisão do dono.

(a) settings — TOCTOU real, e o produto atravessa essa janela sozinho

route.ts:113 faz o SELECT, route.ts:123 decide com base nele, e route.ts:142 escreve
sem levar a condição junto. Entre a leitura e a escrita a sala pode ter virado racing.

Isso não é um vetor só de curl. O caminho legítimo do produto já dispara os dois POSTs em
sequência: a votação de linguagem (#72) faz settings e depois start como duas requisições
separadas (useRoom.ts:659-672). E o PR Doctor registrou no Diário (05/08, PR #133) que nem
Lobby nem Results têm lock de in-flight
— duplo clique em "Iniciar partida" dispara duas
sequências settings→start concorrentes. Basta o segundo settings chegar depois do primeiro
start para escrever language numa sala já em racing.

Por que isso corrompe o registro histórico: o snippet é sorteado no start
(route.ts:158), mas buildMatchRow e buildScoreRows (room.ts:398-428) gravam
room.language / room.difficulty lidos no finish — não os do start. Uma sala que
correu Python/easy e sofreu um settings no meio entra em matches/scores como Rust/hard.
O jogador digitou um snippet fácil e o recorde dele entra no bucket difícil do ranking — que é
precisamente o eixo que a #92 vai transformar
em filtro de primeira classe.

(b) kick — escrita não confirmada (a classe do #56 que o applyRoomUpdate fechou)

route.ts:226-235 é o único UPDATE da rota que não passa por applyRoomUpdate: confere
kickErr, mas nunca rows. Zero linhas afetadas (sala apagada entre route.ts:113 e o UPDATE)
devolve { ok: true } — o líder vê "expulso" e ninguém foi expulso. É exatamente o "0 linhas
afetadas" que a doc do applyRoomUpdate (route.ts:26-37) diz cobrir, e do qual o kick ficou
de fora.

Acceptance criteria

  • settings escreve sob guarda na própria escrita (status = lobby dentro do UPDATE),
    via RoomUpdateSpec/applyRoomUpdate — no mesmo desenho de startRaceSpec
    (room.ts:669-675) e resetToLobbySpec (room.ts:694-700). A regra é pura e vive em
    src/lib/room.ts, testada; a rota só faz I/O.
  • A checagem lida de route.ts:123-124 pode continuar (falha rápido e com copy melhor), mas
    não é mais a única defesa. O 409 continua sendo o mesmo contrato ("Partida já começou").
  • kick passa por applyRoomUpdate: zero linhas confirmadas vira falha honesta em vez de
    ok: true. Manter os dois erros específicos que já existem (400 alvo inválido, 409
    lista cheia, 503 migração pendente) — o novo desfecho é só o "não confirmado".
  • Teste do ponto de imposição (buraco apontado pelas 3 lentes do quórum da PR fix(security): start só transiciona a partir de lobby — POST não reinicia corrida em andamento (#131) #133 e
    registrado no Diário em 05/08): hoje trocar o if (guard) q = q.match(guard.match) de
    route.ts:47-48 por no-op desfaz as guardas de start e reset com o suíte 100% verde
    e tsc limpo. Um teste deve falhar nessa mutação. É a AC que dá valor real ao resto —
    sem ela a guarda nova nasce igualmente indefesa.
  • pnpm typecheck + pnpm test + pnpm build + node scripts/validate-persistence.mjs
    verdes (pnpm test não é opcional: a CI o roda e o HANDBOOK §6 ainda não o lista).

Dica de abordagem

Duas specs novas em src/lib/room.ts no molde das existentes — algo como
settingsUpdateSpec(patch) (match: { status: "lobby" }, conflito "Partida já começou") e
kickUpdateSpec(kicked) (sem match, só para ganhar a confirmação de linhas). O conflict de
roomUpdateOutcome (room.ts:623-641) já distingue "guarda não bateu" de "não confirmado",
então a copy não precisa de invenção nova.

Para o teste do ponto de imposição, o alvo é o q.match(...) da rota, não a spec pura: um dublê
de sb que registre se o .match() foi chamado com o objeto da spec (o padrão do dublê já
existe desde a #112, em persistMatch).

Escopo e classificação

Verificado em origin/main@eee4b62. Sem duplicata (gh issue list --search por
settings/TOCTOU/guarda/status e por bucket de leaderboard: só a #92, que é outro assunto).

Activity

  1. added
    P2Prioridade normal
    area:multiplayerSalas, Realtime, presença, leaderboard, persistência
    area:securityRLS, service_role, validação de entrada, segredos
    on Aug 6, 2026
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