Repository navigation
Description
O buraco
As duas rotas de sala gravam language e difficulty direto no banco, sem allowlist:
src/app/api/rooms/route.ts:58-59(criar sala) —language: settings.language || "javascript",difficulty: settings.difficulty || "medium".src/app/api/rooms/[code]/route.ts:48-49(actionsettings) —language: s.language || room.language, idemdifficulty.
Qualquer string passa: nome inventado, 2 MB de lixo, caracteres de controle. maxPlayers é o único campo validado (Math.min(Math.max(...), 12)) — os outros dois entram crus.
Por que importa (não fica preso na sala)
O persistMatch copia room.language e room.difficulty para matches e scores ([code]/route.ts:110-131), que alimentam o leaderboard global público e seus filtros. Ou seja: um POST direto na API injeta linguagem/dificuldade arbitrária no ranking que todo mundo vê. As colunas são text not null sem CHECK (supabase/migrations/0001_coderacer_init.sql), então o banco aceita.
Efeitos:
- Poluição do dado público — o filtro por linguagem do leaderboard ganha entradas fantasma; a UI renderiza a string do banco.
- Storage abuse — nenhum limite de tamanho em nenhuma camada: um
languagede megabytes é gravado e depois replicado por Realtime para todos os clientes da sala (aroomstemreplica identity full,0002_realtime_rooms.sql). - Divergência silenciosa —
pickSnippetnormaliza na hora de sortear o snippet (snippets.ts:2721-2733), então a sala joga em JavaScript enquanto a coluna diz outra coisa. O dado do ranking mente sem ninguém notar.
O React escapa a string, então não é XSS — é integridade de dado e abuso de armazenamento.
Acceptance criteria
- Validação de fronteira nas duas rotas:
languagesó é aceito se pertencer às linguagens suportadas desrc/lib/languages.ts;difficultysó se foreasy|medium|hard. - Valor inválido → 400 com mensagem clara (não silenciosamente trocado pelo default): a rota é a fronteira, e falhar alto revela cliente quebrado ou ataque. Ausente/
undefinedcontinua caindo no default de hoje (javascript/medium) — não quebre o fluxo legítimo. - Reuso, sem lista nova: a normalização já existe em
src/lib/languages.ts/normalizeLang(usada porpickSnippet). Exporte umisValidLang/isValidDifficultypuros e chame das duas rotas — uma fonte de verdade, nunca duas listas para dessincronizar. - Cinto e suspensório no banco: nova migration aditiva e idempotente em
supabase/migrations/(p.ex.0004_) comCHECKdedifficulty in ('easy','medium','hard')e um teto de comprimento emlanguagenas 3 tabelas (rooms,matches,scores), viaalter table ... add constraint if not exists/ blocodo $$guardado. Agente só cria o arquivo — o dono aplica (CLAUDE.md / HANDBOOK §8). -
validate-persistence.mjscobre os validadores puros: linguagem/dificuldade válida passa · inválida rejeita · ausente cai no default. - Fluxo real intacto: criar sala pelas 14 linguagens do modal e trocar dificuldade no lobby continua funcionando.
Fora de escopo (não faça aqui)
Rate limiting em POST /api/rooms (criação ilimitada de salas → spam na home pública). É um problema real e adjacente, mas exige escolher mecanismo (KV/Upstash = dependência/serviço novo → HANDBOOK §7.1, decisão do dono). Fica registrado para uma rodada futura; não puxe para dentro desta issue.
Área de quórum
Toca src/app/api/rooms/** + migration aditiva → HANDBOOK §7.2: PR non-draft com a linha exata Solicito quórum (HANDBOOK §7).
Activity
caioross commentedon Jul 15, 2026
🏛️ Parecer do Conselho · 2026-07-14
Veredito: APROVADA PARA EXECUÇÃO
src/app/api/rooms/route.ts(POST create):language: settings.language || "javascript"edifficulty: settings.difficulty || "medium"(l.~60-61) — string crua do cliente. Emsrc/app/api/rooms/[code]/route.ts(actionsettings):language: s.language || room.language,difficulty: s.difficulty || room.difficulty. Ambas gravam emroomse propagam paramatches/scoresviapersistMatch(que copiaroom.language/room.difficultynos inserts). Fix: allowlist server-side.src/lib/languages.tsjá temLANGUAGES(14 ids) e o tipoDifficulty(easy|medium|hard) — é data pura, seguro importar no servidor. Criar helpers puros/testáveis junto desanitizeResultsemsrc/lib/room.ts.language/difficulty, que viram dimensões dos filtros/aggregações do leaderboard (getLeaderboardemsrc/lib/supabase.ts), poluindo o Ranking Global e quebrandolangByIdno cliente (retornaundefined→ UI). Não é XSS (Supabase parametriza), mas é poluição de dados + abuso de cardinalidade. Coerção deny-by-default (inválido → default, nunca erro) preserva UX. Toca persistência/anti-cheat → §7.2: PR non-draft + "Solicito quórum (HANDBOOK §7)".javascript/medium); não quebra o fluxo do líder.Set); não toca a área sagrada.Plano sugerido:
src/lib/room.ts:const LANG_IDS = new Set(LANGUAGES.map(l => l.id));+export function coerceLang(x: unknown): LangId { return typeof x === "string" && LANG_IDS.has(x) ? x as LangId : "javascript"; }ecoerceDifficultyanálogo (easy|medium|hard, defaultmedium).POST /api/rooms:coerceLang(settings.language)/coerceDifficulty(settings.difficulty).settings: idem, com fallback para o valor atual da sala.room.test.ts/validate-persistence.mjs: id inválido → default; id válido → preservado.caioross commentedon Jul 15, 2026
Resolvida em #38 (non-draft, quórum §7.2).
Allowlist de
language/difficultynas duas rotas de sala (isValidLang/isValidDifficulty+resolveLang/resolveDifficultypuros emsrc/lib/room.ts, reusandosrc/lib/languages.ts): presente inválido → 400; ausente → default. Migration0004aditiva/idempotente (CHECK difficulty + teto delanguage,NOT VALID) — só criei o arquivo, o dono aplica. Gate verde (typecheck·build·validate-persistence 49/49).Diverge do Parecer numa escolha: segui o AC (inválido → 400) em vez da coerção silenciosa sugerida. Detalhes no PR.
fix(rooms): reset não depende de kicked_ids e kick falha alto sem a m…
refactor(practice): TypingCore e /api/snippet reusam as libs já na main