Repository navigation
Description
Problema — criar sala é grátis, ilimitado e permanente
POST /api/rooms aceita qualquer request, sem freio nenhum, e cada uma grava uma linha em
rooms que nunca é apagada. Não existe rate limit, autenticação, prova de trabalho nem TTL
em lugar algum do projeto — verificado nesta rodada em origin/main:
- Não há
middleware.ts,vercel.json, cron, nem qualquer rotina de limpeza no repo
(git ls-tree -r origin/main+ grep porratelimit|cron|cleanup|expireemsrc/e
supabase/: zero ocorrências relevantes). - A única exigência da rota é um
playerIdnão-vazio, escolhido pelo cliente
(src/app/api/rooms/route.ts:42-44) —
"x"serve. - O insert em
route.ts:67-80é definitivo.
O filtro de 3h em route.ts:19-25 esconde
salas velhas da home, mas só da vitrine — a linha continua no banco para sempre:
const since = new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString();Impacto
- Crescimento sem teto no banco de produção. Um
forde 3 linhas contra/api/roomscria
linhas indefinidamente.roomstemreplica identity fulle está na publicação realtime
(0002_realtime_rooms.sql:45),
então cada linha custa mais que o próprio texto. - É o banco real, sem plano de contenção: estourar a cota do projeto Supabase não derruba
uma tabela, derruba o leaderboard, as salas e a persistência juntos. - Lixo permanente sem custo de ataque: salas abandonadas em
lobby/racingficam eternas —
nada nunca muda o status delas de volta. Não é preciso má-fé; o uso normal já acumula.
Custo do ataque: zero. Sem login, sem captcha, sem chave. Nenhum incidente registrado até hoje —
isto é prevenção, não incêndio, e por isso é P2 e não P0.
Abordagem sugerida (fatia de 1 rodada, sem dependência nova)
Faça só o rate limit da criação. O TTL entra depois, separado — apagar dado é do dono (§7.1).
- Função pura em
src/lib/(rateLimit.tsou dentro deroom.ts), no padrão que o
projeto já usa para toda regra de fronteira — decisão pura e testável, I/O só na rota:
hitRateLimit(state, key, now) → { allowed, retryAfterMs }, janela deslizante,
constantes exportadas (ex.:ROOM_CREATE_MAX = 5,ROOM_CREATE_WINDOW_MS = 60_000). - Rota chama a função e responde 429 com header
Retry-Aftere copy neutra em
português, no mesmo tom dos outros erros da rota. - Chave = IP (
x-forwarded-forna Vercel), com fallback honesto quando ausente. Não use
playerId: ele é escolhido pelo cliente, então trocá-lo burla o limite. - Estado em memória do módulo (
Map), com poda dos buckets vencidos na própria chamada.
Limite conhecido, declare-o na PR: funções serverless escalam por instância, então um limite
em memória é por-instância, não global. Isso reduz o vetor trivial (um script, um IP) e não
resolve um atacante distribuído — a solução global exigiria store externo (Upstash/Redis), que é
serviço novo e cai no núcleo §7.1. Não introduza dependência nem serviço nesta fatia. Um limite
imperfeito e honesto vale mais que nenhum; o que não vale é uma PR dizendo que resolveu o caso
distribuído.
Para o dono, quando quiser podar o histórico (fora do escopo desta issue — é DELETE em
dados de produção, §7.1, e nenhum agente executa):
delete from public.rooms
where created_at < now() - interval '7 days'
and status <> 'racing';Acceptance criteria
- A função de decisão é pura e determinística (recebe
now, não chamaDate.now()
dentro), e está coberta por teste — Vitest ouscripts/validate-persistence.mjs, seguindo
o que já existe parasanitizeResults/canKick. - Casos cobertos: abaixo do limite → passa · no limite → passa · acima → bloqueia ·
janela expirada → volta a passar · chaves diferentes não interferem entre si. - Estourar o limite devolve 429 com
Retry-Aftere mensagem em pt-BR; nenhuma linha
é inserida emroomsnesse caso. - Criar sala pelo fluxo normal da UI continua funcionando sem tropeçar no limite — inclusive
criar, entrar, sair e criar outra em sequência. - Nenhuma dependência nova no
package.json; nenhum serviço externo. - A limitação por-instância está escrita na PR e no código, sem prometer o que não entrega.
Toca src/app/api/rooms/** → área de quórum, HANDBOOK §7.2: PR non-draft com a linha
Solicito quórum (HANDBOOK §7).
Fora de escopo
TTL / limpeza de salas antigas (é DELETE em produção — do dono). Rate limit das outras actions
de sala. Enumeração de salas privadas → #103.
Activity
caioross commentedon Jul 31, 2026
🏛️ Parecer do Conselho · 2026-07-31
Veredito: APROVADA PARA EXECUÇÃO — com 3 ACs adicionais. Duas delas não são polimento: sem a primeira o limite é contornável com um header, e sem a segunda a fatia troca crescimento ilimitado no Postgres por crescimento ilimitado no heap da lambda.
Auditado contra
origin/main @ 098372e(o clone do dono está atrás daorigin).Engenharia: Diagnóstico confirmado:
route.ts:35-45só exigeplayerIdnão-vazio, o insert de:69-81é definitivo, e o filtro de 3h de:19é só vitrine. Ordem importa: o limitador deve ser a primeira coisa doPOST— antes degetServerSupabase()(:36) e antes deawait req.json()(:41). Hoje um atacante faz o servidor parsear JSON arbitrário antes de qualquer freio; a chave é o IP, então nada da decisão depende do corpo.Sobre a pureza da AC 1: a função é pura, mas o
Mapé estado mutável — a assinatura da issue (hitRateLimit(state, key, now)) resolve isso e deve ser seguida ao pé da letra, comstatesendo umMapque a rota possui (nível de módulo) e a função nunca cria. E a poda/despejo tem de morar dentro dehitRateLimit: se a rota podar por fora, os testes não cobrem a parte que mais importa. UmMapnovo por teste dá isolamento total.Detalhe para não contar errado: o laço de
:67-90tenta até 5 vezes, mas só o insert bem-sucedido persiste linha (23505é retry) — o limite conta requests, não linhas, e está certo assim.Segurança/Anti-cheat: (1) A chave é o ponto que faz ou quebra esta fatia. Qualquer cliente pode mandar
X-Forwarded-For: <aleatório>a cada request. Se a Vercel acrescenta o IP real em vez de sobrescrever o header, entãoxff.split(",")[0]— a escolha idiomática, e a que a redação da issue induz — lê o valor do atacante e o limite cai com um header por request. Recomendo preferir um header posto pela plataforma (x-vercel-forwarded-for, oux-real-ip) comx-forwarded-forcomo fallback, e verificar empiricamente num deploy de preview o que cada header realmente contém antes de escolher — não confie neste parecer nem no meu palpite de precedência. A precedência escolhida e o motivo vão em comentário no código; é exatamente o tipo de decisão que o quórum §7.2 existe para crivar.(2) Chave ausente não pode virar um balde só. Se não houver IP utilizável e todos caírem sob
"unknown", um único cliente que remova o header consome o balde e bloqueia todo mundo sem chave — o limite vira o DoS. Sem chave → deixe passar (e conte à parte, para dar visibilidade), nunca compartilhe balde.(3) O
Mapé, ele próprio, um vetor de crescimento. "Podar os buckets vencidos na própria chamada" só poda com tráfego: uma rajada de N chaves distintas insere as N entradas antes de qualquer uma expirar. Precisa de teto duro (MAX_TRACKED_KEYS, ex.: 10_000) com despejo determinístico e testável. Recomendo despejar a entrada de janela mais antiga e continuar servindo — falhar-fechado com a tabela cheia deixaria um atacante distribuído trancar usuários legítimos, que é pior que o bug original.UX/Game-feel: Boa notícia, e ela vale uma AC: o cliente já está pronto e não deve ser tocado.
HomeView.tsx:124-129ignorares.statuse renderizajson.errornum toast, com"Erro ao criar sala"de fallback. Então um 429 que mantenha o contrato{ ok: false, error: "<pt-BR>" }— o mesmo de:44,:53,:60,:88,:92— aparece corretamente na tela sem uma linha de mudança no cliente. Inverso também é verdade: se a resposta 429 fugir desse shape, o jogador leva "Erro ao criar sala" genérico e ninguém entende o freio. A copy deve dizer o que fazer ("aguarde um instante e tente de novo"), no tom das outras.Retry-Afteré para o protocolo; o texto é para a pessoa.Performance: Fora da área sagrada (criação de sala, nenhuma tecla em voo) e o efeito é positivo: rejeitar antes do
req.json()e antes do Supabase corta um round-trip de banco por request abusivo.Mapem memória de módulo é ~O(1); o único custo real de latência seria uma poda O(n) varrendo tudo — com o teto do item (3), n é limitado e a varredura é barata. Nada disso vai ao bundle do cliente (rotanodejs,route.ts:6).Produto: P2 correta — é prevenção, não incêndio, e a issue é honesta sobre isso. Uma ressalva de persona sobre o número:
ROOM_CREATE_MAX = 5por 60s é por IP, e IP é compartilhado — sala de aula, escritório, CGNAT de operadora móvel. "O Social/Casual" ganha com onboarding zero-fricção e "O Mobile" cai por link no celular (§0.3): dez amigos criando salas atrás de um NAT batem no teto sem fazer nada de errado. A diferença entre 5 e 10 por minuto é nula para um script e enorme para uma turma — recomendoROOM_CREATE_MAX = 10/ 60s como ponto de partida, com as constantes exportadas para ajuste barato depois.Plano sugerido:
src/lib/rateLimit.ts(novo, puro):hitRateLimit(state: Map<string, ...>, key: string, now: number): { allowed: boolean; retryAfterMs: number }, janela deslizante, com poda e despejo porMAX_TRACKED_KEYSdentro da função. ExporteROOM_CREATE_MAX,ROOM_CREATE_WINDOW_MS,MAX_TRACKED_KEYS. NenhumDate.now()dentro.src/lib/clientKey.tsou helper no mesmo arquivo:clientKeyFromHeaders(h: Headers): string | null— precedência escolhida após verificar num preview (item Segurança 1),nullquando não há IP confiável. Comentário no código explicando a escolha.src/app/api/rooms/route.ts:35—Mapno nível do módulo; chamada como primeira instrução doPOST.allowed === false→NextResponse.json({ ok: false, error: "Muitas salas criadas deste endereço — aguarde um instante e tente de novo." }, { status: 429, headers: { "Retry-After": String(Math.ceil(retryAfterMs / 1000)) } }). Chavenull→ passa.src/lib/rateLimit.test.ts(vitest, ao lado deroom.test.ts): abaixo do limite passa · no limite passa · acima bloqueia comretryAfterMs > 0· janela expirada volta a passar · chaves distintas não interferem ·MAX_TRACKED_KEYSestourado não cresce oMape não bloqueia chave nova ·clientKeyFromHeaderssem header →null.pnpm typecheck && pnpm build,vitest,validate-metrics,validate-persistence. Nenhuma dependência nova. §7.2 — PR non-draft comSolicito quórum (HANDBOOK §7).Nota de escopo, para outra rodada (não amplie esta PR): o
GETda mesma rota (route.ts:13-32) também não tem freio e faz uma query no Supabase por request — é o alvo mais barato dos dois, já que não insere nada. Vale issue própria; misturar aqui estraga a fatia.