fix(security): allowlist de language/difficulty nas rotas de sala (#35) - #38
Conversation
As rotas de criação (POST /api/rooms) e de ajuste (action `settings` em POST /api/rooms/[code]) gravavam `language`/`difficulty` como string crua do cliente, sem allowlist. Esses campos são copiados por `persistMatch` para `matches`/`scores` — o leaderboard global público —, então um POST direto injetava dimensões arbitrárias no ranking e gravava payloads sem teto de tamanho (replicados por Realtime a toda a sala). - src/lib/room.ts: `isValidLang`/`isValidDifficulty` (predicados puros) e `resolveLang`/`resolveDifficulty` (política de fronteira), reusando `LANGUAGES`/`DIFFICULTIES` de src/lib/languages.ts — uma fonte de verdade. - Rotas: valor presente inválido → 400 com mensagem clara; ausente cai no default (create) ou no valor atual da sala (settings), sem quebrar o fluxo. - supabase/migrations/0004: CHECK aditivo/idempotente de difficulty e teto de comprimento de language nas 3 tabelas (NOT VALID para não falhar em dados legados). Apenas criado — o dono aplica. - scripts/validate-persistence.mjs: cobre válido/inválido/ausente dos validadores. Closes #35
|
@caioross is attempting to deploy a commit to the caioross' projects team on Vercel, but is not a member of this team. To resolve this issue, you can:
To read more about collaboration on Vercel, click here. |
There was a problem hiding this comment.
Code Review
This pull request introduces robust validation for room settings (language and difficulty) across the API endpoints, test scripts, and database constraints to prevent invalid values or storage abuse. The reviewer feedback suggests a defensive programming improvement to validate the fallback parameters in resolveLang and resolveDifficulty (both in the main implementation and the test mirror) to avoid potential 500 errors if legacy invalid data exists in the database.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| export function resolveLang(raw: unknown, fallback: LangId): FieldResolution<LangId> { | ||
| if (raw == null || raw === "") return { ok: true, value: fallback }; | ||
| return isValidLang(raw) ? { ok: true, value: raw } : { ok: false }; | ||
| } | ||
|
|
||
| export function resolveDifficulty( | ||
| raw: unknown, | ||
| fallback: Difficulty | ||
| ): FieldResolution<Difficulty> { | ||
| if (raw == null || raw === "") return { ok: true, value: fallback }; | ||
| return isValidDifficulty(raw) ? { ok: true, value: raw } : { ok: false }; | ||
| } |
There was a problem hiding this comment.
Se a sala atual possuir dados legados inválidos no banco de dados (por exemplo, um language ou difficulty corrompido ou com tamanho maior que o permitido), o uso de room.language ou room.difficulty como fallback nas rotas de API pode propagar esse valor inválido durante uma atualização de configurações (como alterar o número máximo de jogadores). Isso fará com que a nova constraint do banco de dados rejeite a transação, resultando em erro 500 para o usuário.
Para evitar isso e garantir programação defensiva, podemos validar o fallback em tempo de execução dentro de resolveLang e resolveDifficulty, caindo para um valor padrão seguro caso o fallback seja inválido.
| export function resolveLang(raw: unknown, fallback: LangId): FieldResolution<LangId> { | |
| if (raw == null || raw === "") return { ok: true, value: fallback }; | |
| return isValidLang(raw) ? { ok: true, value: raw } : { ok: false }; | |
| } | |
| export function resolveDifficulty( | |
| raw: unknown, | |
| fallback: Difficulty | |
| ): FieldResolution<Difficulty> { | |
| if (raw == null || raw === "") return { ok: true, value: fallback }; | |
| return isValidDifficulty(raw) ? { ok: true, value: raw } : { ok: false }; | |
| } | |
| export function resolveLang(raw: unknown, fallback: LangId): FieldResolution<LangId> { | |
| if (raw == null || raw === "") { | |
| const safeFallback = isValidLang(fallback) ? fallback : "javascript"; | |
| return { ok: true, value: safeFallback }; | |
| } | |
| return isValidLang(raw) ? { ok: true, value: raw } : { ok: false }; | |
| } | |
| export function resolveDifficulty( | |
| raw: unknown, | |
| fallback: Difficulty | |
| ): FieldResolution<Difficulty> { | |
| if (raw == null || raw === "") { | |
| const safeFallback = isValidDifficulty(fallback) ? fallback : "medium"; | |
| return { ok: true, value: safeFallback }; | |
| } | |
| return isValidDifficulty(raw) ? { ok: true, value: raw } : { ok: false }; | |
| } |
| function resolveLang(raw, fallback) { | ||
| if (raw == null || raw === '') return { ok: true, value: fallback }; | ||
| return isValidLang(raw) ? { ok: true, value: raw } : { ok: false }; | ||
| } | ||
| function resolveDifficulty(raw, fallback) { | ||
| if (raw == null || raw === '') return { ok: true, value: fallback }; | ||
| return isValidDifficulty(raw) ? { ok: true, value: raw } : { ok: false }; | ||
| } |
There was a problem hiding this comment.
Para manter o espelho de testes em sincronia com a implementação de src/lib/room.ts, atualize as funções resolveLang e resolveDifficulty para também validarem o fallback em tempo de execução.
function resolveLang(raw, fallback) {
if (raw == null || raw === '') {
const safeFallback = isValidLang(fallback) ? fallback : 'javascript';
return { ok: true, value: safeFallback };
}
return isValidLang(raw) ? { ok: true, value: raw } : { ok: false };
}
function resolveDifficulty(raw, fallback) {
if (raw == null || raw === '') {
const safeFallback = isValidDifficulty(fallback) ? fallback : 'medium';
return { ok: true, value: safeFallback };
}
return isValidDifficulty(raw) ? { ok: true, value: raw } : { ok: false };
}
🩺 Quórum adversarial (HANDBOOK §7.2) — 3× APROVA ✅Classificação: quórum — toca Três lentes adversariais em paralelo (default VETAR, vetor
⏸️ Parking-lot — pronto, merge parado pelo gate de produçãoO que falta: nada de código. Quórum 3×APROVA, CI verde, |
|
🏛️ Quórum adversarial (HANDBOOK §7.2) — 3× APROVA · head
|
União com origin/main (conflito em Race.tsx) mantendo uma só fonte de verdade em vez de reintroduzir cópias que a main acabou de eliminar. - Race.tsx fica na forma composta desta branch (RaceTrack + TypingCore + chat); o cálculo inline que o #32 tirou de lá não volta. - TypingCore.tsx passa a importar countCorrectChars/computeWpm/computeAccuracy/ computeProgress de @/lib/metrics (#32, mergeada no PR #48). A extração era 1:1 do Race.tsx pré-#32; sem isso o merge recriava a duplicação de fórmula que a #32 existiu para matar. `correctChars` segue no useMemo (área sagrada §2). - /api/snippet usa resolveLang/resolveDifficulty de @/lib/room (#35, mergeada no PR #38) no lugar da checagem própria contra LANGUAGES/DIFFICULTIES — mesma política de fronteira das rotas de sala, uma implementação só. Gate: typecheck OK · build OK (/practice 149 kB estática, /api/snippet dinâmica) · vitest 34/34 · validate-metrics 37/0 · validate-persistence 62/0. Refs #25 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contexto
As duas rotas de sala gravavam
language/difficultycomo string crua do cliente, sem allowlist:POST /api/rooms(criar) —language: settings.language || "javascript".settingsemPOST /api/rooms/[code]—language: s.language || room.language.persistMatchcopia esses campos paramatches/scores, que alimentam o leaderboard global público. Ou seja, um POST direto injetava linguagem/dificuldade arbitrária no ranking (poluição de dado + abuso de cardinalidade nos filtros) e gravava payloads sem teto de tamanho — depois replicados por Realtime a toda a sala. Não é XSS (Supabase parametriza); é integridade de dado e storage abuse.O que mudou e por quê
src/lib/room.ts—isValidLang/isValidDifficulty(predicados puros, type-guards) +resolveLang/resolveDifficulty(política de fronteira compartilhada). ReusamLANGUAGES/DIFFICULTIESdesrc/lib/languages.ts— uma fonte de verdade, nunca duas listas para dessincronizar. Ficam junto desanitizeResults, a outra fronteira anti-cheat da mesma tabela.undefined/null/"") cai no default (create) ou no valor atual da sala (settings).maxPlayerssegue como estava.supabase/migrations/0004_settings_allowlist.sql— cinto e suspensório no banco:CHECK difficulty in ('easy','medium','hard')+ teto de comprimento emlanguagenas 3 tabelas (rooms/matches/scores). Aditiva e idempotente;NOT VALIDde propósito, para valer em INSERT/UPDATE novos sem falhar a aplicação por linhas legadas. Apenas criei o arquivo — o dono aplica (CLAUDE.md / HANDBOOK §8).scripts/validate-persistence.mjs— cobre os validadores puros: válido preserva · inválido rejeita · ausente cai no default · actionsettingsmantém o valor atual.Divergência do Parecer do Conselho (1 linha)
O Conselho sugeriu coerção silenciosa (inválido → default). Segui o acceptance criteria da issue, que é explícito e mais robusto: inválido → 400 (falhar alto revela cliente quebrado ou ataque; ausente continua no default, sem quebrar o fluxo legítimo).
Resultado do gate
pnpm install --frozen-lockfile✓pnpm typecheck✓pnpm build✓ (8/8 páginas)node scripts/validate-persistence.mjs✓ 49 passaram, 0 falharamlint— N/A (sem config ESLint no repo; a CI não roda lint)validate-metrics— N/A (não toca a engine de digitação)Riscos
NOT VALIDnão limpa dado histórico já gravado; protege daqui pra frente. O dono podevalidate constraintdepois de higienizar, se quiser.POST /api/rooms— exige serviço novo (HANDBOOK §7.1).Toca
src/app/api/rooms/**+ migration aditiva.Solicito quórum (HANDBOOK §7)
Closes #35