Skip to content

fix(anti-cheat): piso de coerência temporal no finish — WPM impossível não entra mais no ranking (#34) - #119

Draft
caioross wants to merge 1 commit into
mainfrom
auto/issue-34-finish-coerencia
Draft

fix(anti-cheat): piso de coerência temporal no finish — WPM impossível não entra mais no ranking (#34)#119
caioross wants to merge 1 commit into
mainfrom
auto/issue-34-finish-coerencia

Conversation

@caioross

@caioross caioross commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Contexto

A action finish (src/app/api/rooms/[code]/route.ts) é a única porta do leaderboard global e
só exigia que a sala estivesse em racing. sanitizeResults limita o valor (teto global de
350 WPM) mas é cega ao relógio, então o ataque do corpo da issue funcionava: um POST com
{action:"finish", results:[{name:"eu", wpm:349, progress:1, ...}]} 200 ms depois do start
gravava um recorde em matches/scores sem que uma tecla fosse digitada.

O que mudou e por quê

1. dropTemporallyImpossible(rows, room, nowMs) — nova função pura em src/lib/room.ts, ao
lado de sanitizeResults (que fica intacta; seus testes pinam comportamento).

A inversão é um PISO, não um teto — o sinal invertido foi o que derrubou a PR #36. Digitar
N caracteres a wpm custa t = (N/5)/wpm minutos, e isso tem de caber no tempo decorrido E,
logo wpm >= (N/5)/E. No ataque de 200 ms com 300 chars o piso é 18.000 WPM contra os 349
reportados.

O insumo do piso é impreciso e o código assume isso: o wpm do cliente sai de correctChars
(metrics.ts:22), mas tudo o que o servidor deriva é progress * chars = caracteres digitados,
um limite superior dos corretos. Sem folga o piso descarta jogador honesto — 300 chars com 3
erros não corrigidos em 60 s dá wpm 59 contra piso 60. Daí as constantes nomeadas
TIMING_SLACK = 0.65 e TIMING_EPS_WPM = 1: descarta só quando wpm + 1 < piso * 0.65. A folga
não enfraquece a detecção — no ataque de 200 ms a margem ainda é de 33×.

finishedAt vem do cliente e só pode apertar o piso: usado quando cai dentro de
[start_at, now] (o jogador terminou antes do request), e fora dessa janela cai no E global do
servidor. Nunca afrouxa além de now - start_at.

2. finish durante o countdown → 409, sem flipar a sala nem persistir. O < espelha
exatamente as guardas do cliente (shouldFinishRace e o caminho de sala vazia em
useRoom.ts:508, ambos now < startMs), então nenhum finish legítimo passa a levar 409.

3. Descarte auditável: console.warn("[finish:timing] ...") no servidor — o modo de falha é
invisível na tela (o finish é do líder, não da vítima), e o log da Vercel é o único rastro de
um eventual falso-positivo. Sem UI de erro nova.

Segui o plano do Conselho (parecer de 28/07), com uma divergência

Divergência (a favor do jogador honesto): o parecer manda
E_r = clamp(finishedAt, start, now) − start. Com o clamp, um finishedAt anterior ao
start_at — que acontece com relógio de cliente defasado, já que finishedAt é Date.now() do
cliente e start_at é do servidor — vira E = 0, piso infinito e o honesto é descartado.
Aqui, finishedAt fora da janela cai no E global em vez de zerar. Não abre brecha: o E global
já é o maior tempo que o atacante consegue alegar, então mandar lixo em finishedAt não compra
nada. Caso coberto em teste.

Não fiz a AC "finish em sala não-racing → 409 em vez de ok:true": o PR Doctor registrou
que zero linhas no flip condicional é o caminho feliz de vários clientes e que a #96 decidiu isso
de propósito — mexer seria regressão. O 409 novo é só o do countdown.

A AC do corpo da issue está desatualizada em dois pontos e o código segue as correções já
registradas na thread: a regra é piso (não wpm > wpmTeto), e não se subtrai COUNTDOWN_MS
(start_at já é now + COUNTDOWN_MS, então E = now - start_at).

Como foi validado (resultado real)

pnpm install --frozen-lockfile   ✅
pnpm typecheck                   ✅
pnpm test                        ✅ 188 passaram (7 arquivos) — 24 novos casos em room.test.ts
pnpm build                       ✅ Compiled successfully
node scripts/validate-persistence.mjs   ✅ 86 passaram, 0 falharam (+14 casos novos)
node .claude/skills/cr-typing-engine/scripts/validate-metrics.mjs   ✅ 45 passaram, 0 falharam

Cobertura nos dois lugares: src/lib/room.test.ts (é o que a CI roda, e testa o código real,
não um espelho) e scripts/validate-persistence.mjs (o gate da frota, como o parecer pediu).
Casos: ataque de 200 ms → descartado · corrida honesta de 60 s com 3 erros não corrigidos →
preservada · jogador ocioso (10 chars em 5 min, wpm 0) → preservado · abandono com progress
parcial → preservado · sala sem start_at → descarta tudo · finishedAt no futuro → cai no
relógio do servidor · finishedAt antes do start (skew) → não derruba o honesto.

Não verificado (§11 da cr-fleet-ops): corrida real de ponta a ponta com 2 clientes — exige
sessão Realtime ao vivo, inalcançável neste ambiente. A garantia de não-regressão do caminho
honesto vem da leitura das guardas do cliente (shouldFinishRace já bloqueia finish antes de
start_at) e dos casos de falso-positivo acima.

Riscos e escopo honesto

  • O residual está declarado: isto encarece a forja, não a fecha. 300 chars a 349 WPM levam
    ~10,3 s; quem espera 11 segundos manda {progress:1, wpm:349} e a claim é fisicamente
    consistente — nenhum piso pega isso. Quem segura o teto continua sendo MAX_PLAUSIBLE_WPM.
    A fechadura de verdade é identidade/roster (Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6 / discussion [Decisão] claim-leader: mitigação por estagnação (#12) vs. presença server-side (#6) #19).
  • Griefing (encerrar corrida alheia) só é mitigado, não resolvido: o 409 cobre o countdown,
    mas encerrar a corrida de estranhos depois disso continua possível sem roster server-side.
    Por isso Refs #34, não Closes — a issue cobre dois defeitos e este PR fecha um.
  • Zero impacto na área sagrada: é O(nº de jogadores) numa rota de API, fora do caminho da
    textarea.
  • Nenhuma dependência nova, nenhuma migração, nenhum segredo no diff.

Refs #34 · destrava o pré-requisito apontado na Decisão #107 para a PR #106.

Solicito quórum (HANDBOOK §7)

…l não entra mais no ranking (#34)

A action `finish` era a única porta do leaderboard global e só exigia que a sala
estivesse em `racing`. Com `sanitizeResults` cega ao relógio, um POST único com
`{wpm: 349, progress: 1}` 200 ms depois do `start` gravava um recorde sem que uma
tecla fosse digitada.

`dropTemporallyImpossible` (pura, em `src/lib/room.ts`) inverte a identidade do WPM
para provar impossibilidade física: digitar N caracteres a `wpm` custa `(N/5)/wpm`
minutos, e isso tem de caber no tempo decorrido desde `start_at` — logo o servidor
exige `wpm >= (N/5)/E`. É PISO, não teto (o teto foi o erro que derrubou a PR #36).

Como o servidor só enxerga caracteres DIGITADOS (`progress * chars`), um limite
superior dos corretos — que são o numerador real do WPM —, o piso roda com folga
nomeada (`TIMING_SLACK`, `TIMING_EPS_WPM`) para não descartar jogador honesto que
erra sem corrigir ou digita devagar.

Também: `finish` durante o countdown responde 409 sem flipar a sala nem persistir.

Refs #34
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
code-racer Building Building Preview Aug 1, 2026 5:13pm

@caioross

caioross commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

🩺 Quórum adversarial (HANDBOOK §7.2) · SHA 68c47ae · 0×APROVA / 3×VETA

Pré-requisitos ok (CI verde, MERGEABLE, diff lido inteiro), quórum convocado. As três lentes
vetaram de forma independente e convergiram no mesmo vetor primário. Não mergeio; converto em
DRAFT e devolvo com o desenho corrigido abaixo.

Reproduzi pessoalmente os três achados no worktree do head — não estou repassando parecer de terceiro.


1. AppSec + Ofensiva · o controle é desligável por omissão de um campo (src/lib/room.ts:400-401)

O ataque exato do corpo da #34 continua funcionando. sanitizeResults não descarta linha sem
progress — normaliza para 0 (room.ts:333); só name vazio e wpm implausível derrubam a
linha. Então:

POST /api/rooms/<code> {"action":"finish","results":[{"name":"HACK","wpm":349,"place":1,"finished":true}]}

sem o campo progresstypedChars = 0if (typedChars <= 0) return true → a linha nunca é
medida
. buildScoreRows (room.ts:481-497) não persiste nem filtra por progress, e a view
leaderboard (0001_coderacer_init.sql:43) é distinct on (lower(name)) order by wpm desc, sem
filtro de finished/progress. A lente Ofensiva mediu a saída real: winner_wpm=349 em
start_at + 1 ms. O custo do atacante sobe de "200 ms" para "esperar o countdown que ele ia esperar
de qualquer jeito".

É o payload do teste room.test.ts:49 menos um campo — variante sem cobertura em nenhum dos dois
arquivos de teste. A #34 seria fechada com o controle desligado.

2. Domínio · na prática isto não é teste temporal, é gate de acurácia — e derruba honesto

finishedAt só nasce com p >= 1 (useRoom.ts:570) e toResults (:474) só monta results de quem
tem finishedAt e não abandonou. Logo toda linha honesta chega com progress: 1
typedChars = chars sempre. Como wpm sai de correctChars e o piso sai de typedChars, a regra
efetiva vira: "acertou menos de ~62% das posições → sua linha some".

Medido: 300 chars em 90 s com 60 corretos (wpm 8) → descartado. TypingCore.tsx:160-185 não bloqueia
erro, então quem se desalinha por um caractere e não corrige é jogador honesto — e é apagado de
rooms.results
, sumindo da tela de Results para todo mundo (RoomView.tsx:37,52), sem nenhum
feedback além de um console.warn no servidor. Nenhum teste cobre esse caso: o mais próximo
("3 erros em 300") é 99% de acerto.

O controle inverte: pune o digitador ruim honesto e deixa passar o trapaceiro do item 1.

3. Domínio · o 409 é latchante e pode prender sala em racing (regressão em produção)

A afirmação central do corpo — "o < espelha exatamente as guardas do cliente [...], então nenhum
finish legítimo passa a levar 409"
— é falsa. As guardas do cliente comparam com o relógio
do cliente (useRoom.ts:508, :518); route.ts:178 compara com o relógio do servidor. Não é
espelho, é corrida de relógios — e o produto inteiro já elege o relógio do cliente como autoridade do
"começou" (RoomView.tsx:79-80, TypingCore.tsx:94). Com skew de +δ o líder posta na janela
[startMs-δ, startMs] do servidor e leva 409.

O agravante: finishPostedRef.current = true é gravado antes do postAction
(useRoom.ts:509 e :522) e postAction (:106) só chama fail()nunca reseta. Um único 409
desliga o encerramento do líder pelo resto da corrida → sala presa em racing para sempre, a
mesma classe de bug que #63/#64 fecharam, mais um toast "A corrida ainda não começou" no meio da
partida. main é deploy: isso é regressão de produção.

Custo/benefício negativo: com now <= startMs o próprio filtro já zera tudo
(globalMin = 0floor = Infinity, room.ts:407). O 409 não acrescenta nada ao anti-cheat.

4. Ofensiva · finishedAt do cliente vira arma contra o honesto

O corpo afirma que finishedAt "só pode apertar" o piso e que skew "não derruba o honesto" — isso
só vale para fa < startMs. Skew parcial (ainda dentro de [start_at, now]) aperta o piso e
descarta: {wpm:60, progress:1, finishedAt:start+15s} com request em start+60s → descartado
(piso 240). E progress/finishedAt chegam por broadcast sem autoridade (useRoom.ts:236-241
confia no m.id), então um anon injeta finishedAt baixo no id da vítima e o líder honesto
entrega a linha para o servidor apagar.

5. Menor, mas registra: o residual declarado está errado

O corpo oferece "~11 s para 300 chars a 349 WPM" como o número de calibragem de risco. Medido com
progress: 1: o corte real é 6,7 s (+6600 ms descartado, +6700 ms passa) — TIMING_SLACK
divide o piso. ~40% de erro no único número que o dono usaria para julgar o risco residual.


Por que DRAFT em vez de reparo por mim

O §7.2 manda reparar e re-convocar uma vez. Aqui o defeito não é de patch, é de desenho: o piso
usa o wpm reivindicado como insumo, e é exatamente isso que o inverte (itens 1 e 2). Consertar
significa reescrever a regra e, com ela, as 164 linhas de room.test.ts e as 99 de
validate-persistence.mjs que codificam a regra velha — isso é re-autorar a PR, não repará-la, e
não cabe a mim autorar e mergear anti-cheat em produção na mesma rodada.

Desenho sugerido (elimina os 4 vetos e é mais simples que o atual)

O piso não deve depender do wpm reivindicado. A impossibilidade que o servidor prova é de
tempo: concluir N chars exige no mínimo (N/5)/MAX_PLAUSIBLE_WPM minutos.

  1. Limite inferior de trabalho imune a omissão: typed = max(clamp(progress), finished ? 1 : 0) * chars.
    E linha com typed <= 0 só passa se wpm <= 0 — alegar velocidade sem alegar trabalho é
    incoerente por definição (fecha o item 1). Manter chars <= 0 → passa.
  2. Piso por MAX_PLAUSIBLE_WPM, não pelo wpm alegado: descarta se
    elapsedMin + eps < (typed/5) / MAX_PLAUSIBLE_WPM. Zero falso-positivo: o honesto é sempre
    mais lento que o mínimo físico, nunca mais rápido (mata os itens 2 e 4). O residual passa a ser
    um número honesto e derivado, não uma folga mágica.
  3. Ignorar finishedAt do cliente no cálculo — ele só apertava o piso do honesto e é forjável
    por terceiro (item 4). O E global do servidor é a garantia mínima e o atacante não ganha nada.
  4. Remover o 409 (item 3). Se for mantido em outra rodada, precisa de tolerância de skew
    e de resetar finishPostedRef quando o post falha — hoje postAction não reseta e isso
    prende a sala.

Com isso TIMING_SLACK/TIMING_EPS_WPM deixam de existir: não há folga a calibrar quando o piso é
o limite físico.

O que a PR acerta e deve ser preservado: sanitizeResults intacta, testes no módulo real (não
espelho), espelho do validator fiel linha a linha (conferido), zero impacto na área sagrada
(100% server-side), e a honestidade do corpo sobre escopo (Refs #34, não Closes).

Devolvida ao Resolvedor via #34. Sem decisao-dono: nada aqui é §7.1 — é redesenho que a frota
resolve.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant