Skip to content

Segurança/API: language e difficulty entram no banco sem allowlist e vazam para matches/scores #35

Closed

Description

@caioross

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 (action settings) — language: s.language || room.language, idem difficulty.

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 language de megabytes é gravado e depois replicado por Realtime para todos os clientes da sala (a rooms tem replica identity full, 0002_realtime_rooms.sql).
  • Divergência silenciosa — pickSnippet normaliza 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: language só é aceito se pertencer às linguagens suportadas de src/lib/languages.ts; difficulty só se for easy|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/undefined continua 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 por pickSnippet). Exporte um isValidLang / isValidDifficulty puros 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_) com CHECK de difficulty in ('easy','medium','hard') e um teto de comprimento em language nas 3 tabelas (rooms, matches, scores), via alter table ... add constraint if not exists / bloco do $$ guardado. Agente só cria o arquivo — o dono aplica (CLAUDE.md / HANDBOOK §8).
  • validate-persistence.mjs cobre 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

  1. added
    P2Prioridade normal
    area:infraCI/CD, build, tooling, deploy, deps
    area:securityRLS, service_role, validação de entrada, segredos
    on Jul 14, 2026
  2. caioross commented on Jul 15, 2026

    @caioross
    OwnerAuthor

    🏛️ Parecer do Conselho · 2026-07-14

    Veredito: APROVADA PARA EXECUÇÃO

    • Engenharia: Dois pontos de entrada crus. Em src/app/api/rooms/route.ts (POST create): language: settings.language || "javascript" e difficulty: settings.difficulty || "medium" (l.~60-61) — string crua do cliente. Em src/app/api/rooms/[code]/route.ts (action settings): language: s.language || room.language, difficulty: s.difficulty || room.difficulty. Ambas gravam em rooms e propagam para matches/scores via persistMatch (que copia room.language/room.difficulty nos inserts). Fix: allowlist server-side. src/lib/languages.ts já tem LANGUAGES (14 ids) e o tipo Difficulty (easy|medium|hard) — é data pura, seguro importar no servidor. Criar helpers puros/testáveis junto de sanitizeResults em src/lib/room.ts.
    • Segurança/Anti-cheat: É o vetor da issue — sem allowlist o cliente injeta strings arbitrárias em language/difficulty, que viram dimensões dos filtros/aggregações do leaderboard (getLeaderboard em src/lib/supabase.ts), poluindo o Ranking Global e quebrando langById no cliente (retorna undefined → 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)".
    • UX/Game-feel: Nulo com defaults sensatos (javascript/medium); não quebra o fluxo do líder.
    • Performance: Desprezível (lookup em Set); não toca a área sagrada.
    • Produto: Higiene e credibilidade do leaderboard a custo baixo. P2 correta; Feat/aaa overhaul #3 no Top-3 do Curador.

    Plano sugerido:

    1. 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"; } e coerceDifficulty análogo (easy|medium|hard, default medium).
    2. POST /api/rooms: coerceLang(settings.language) / coerceDifficulty(settings.difficulty).
    3. Action settings: idem, com fallback para o valor atual da sala.
    4. Testes em room.test.ts/validate-persistence.mjs: id inválido → default; id válido → preservado.
    5. PR §7.2 (quórum).
  3. added
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    and removed
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    on Jul 15, 2026
  4. caioross commented on Jul 15, 2026

    @caioross
    OwnerAuthor

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

    Allowlist de language/difficulty nas duas rotas de sala (isValidLang/isValidDifficulty + resolveLang/resolveDifficulty puros em src/lib/room.ts, reusando src/lib/languages.ts): presente inválido → 400; ausente → default. Migration 0004 aditiva/idempotente (CHECK difficulty + teto de language, 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.

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:infraCI/CD, build, tooling, deploy, depsarea: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