Skip to content

Segurança/Infra: POST /api/rooms sem rate limit — criar salas é grátis, ilimitado e a linha nunca é apagada (rooms/route.ts:42-80) #104

Open

Description

@caioross

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 por ratelimit|cron|cleanup|expire em src/ e
    supabase/: zero ocorrências relevantes).
  • A única exigência da rota é um playerId nã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 for de 3 linhas contra /api/rooms cria
    linhas indefinidamente. rooms tem replica identity full e 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/racing ficam 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).

  1. Função pura em src/lib/ (rateLimit.ts ou dentro de room.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).
  2. Rota chama a função e responde 429 com header Retry-After e copy neutra em
    português, no mesmo tom dos outros erros da rota.
  3. Chave = IP (x-forwarded-for na Vercel), com fallback honesto quando ausente. Não use
    playerId: ele é escolhido pelo cliente, então trocá-lo burla o limite.
  4. 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 chama Date.now()
    dentro), e está coberta por teste — Vitest ou scripts/validate-persistence.mjs, seguindo
    o que já existe para sanitizeResults/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-After e mensagem em pt-BR; nenhuma linha
    é inserida
    em rooms nesse 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

  1. added
    P2Prioridade normal
    area:multiplayerSalas, Realtime, presença, leaderboard, persistência
    area:infraCI/CD, build, tooling, deploy, deps
    area:securityRLS, service_role, validação de entrada, segredos
    on Jul 28, 2026
  2. caioross commented on Jul 31, 2026

    @caioross
    OwnerAuthor

    🏛️ 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 da origin).

    • Engenharia: Diagnóstico confirmado: route.ts:35-45 só exige playerId nã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 do POST — antes de getServerSupabase() (:36) e antes de await 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, com state sendo um Map que a rota possui (nível de módulo) e a função nunca cria. E a poda/despejo tem de morar dentro de hitRateLimit: se a rota podar por fora, os testes não cobrem a parte que mais importa. Um Map novo por teste dá isolamento total.
      Detalhe para não contar errado: o laço de :67-90 tenta 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ão xff.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, ou x-real-ip) com x-forwarded-for como 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-129 ignora res.status e renderiza json.error num 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. Map em 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 (rota nodejs, 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 = 5 por 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 — recomendo ROOM_CREATE_MAX = 10 / 60s como ponto de partida, com as constantes exportadas para ajuste barato depois.

    Plano sugerido:

    1. src/lib/rateLimit.ts (novo, puro): hitRateLimit(state: Map<string, ...>, key: string, now: number): { allowed: boolean; retryAfterMs: number }, janela deslizante, com poda e despejo por MAX_TRACKED_KEYS dentro da função. Exporte ROOM_CREATE_MAX, ROOM_CREATE_WINDOW_MS, MAX_TRACKED_KEYS. Nenhum Date.now() dentro.
    2. src/lib/clientKey.ts ou helper no mesmo arquivo: clientKeyFromHeaders(h: Headers): string | null — precedência escolhida após verificar num preview (item Segurança 1), null quando não há IP confiável. Comentário no código explicando a escolha.
    3. src/app/api/rooms/route.ts:35 — Map no nível do módulo; chamada como primeira instrução do POST. 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)) } }). Chave null → passa.
    4. Testes em src/lib/rateLimit.test.ts (vitest, ao lado de room.test.ts): abaixo do limite passa · no limite passa · acima bloqueia com retryAfterMs > 0 · janela expirada volta a passar · chaves distintas não interferem · MAX_TRACKED_KEYS estourado não cresce o Map e não bloqueia chave nova · clientKeyFromHeaders sem header → null.
    5. Gate: pnpm typecheck && pnpm build, vitest, validate-metrics, validate-persistence. Nenhuma dependência nova. §7.2 — PR non-draft com Solicito quórum (HANDBOOK §7).
    6. Na PR e em comentário no código, declare os dois limites: por-instância (a issue já exige) e a dependência de confiança no header escolhido. Não prometa o caso distribuído.

    Nota de escopo, para outra rodada (não amplie esta PR): o GET da 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.

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:multiplayerSalas, Realtime, presença, leaderboard, persistênciaarea: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