Skip to content

Segurança/RLS: "sala privada" é enumerável — policy using(true) em rooms expõe toda sala ao anon (0002_realtime_rooms.sql:26-29) #103

Open

Description

@caioross

Problema — "sala privada" não é privada: qualquer um lista todas as salas

A tabela rooms é legível por anon sem nenhum filtro, e a chave anon está publicada
no bundle de produção. Resultado: is_public = false não esconde nada — dá para enumerar
todas as salas vivas
, privadas inclusive, com os códigos, e entrar em qualquer uma.

A política que abre a porta (0002_realtime_rooms.sql:26-29):

create policy "public read rooms" on public.rooms for select using (true);
grant select on public.rooms to anon, authenticated;

using (true) = qualquer linha, para qualquer um. A rota
src/app/api/rooms/route.ts:22-24 filtra
is_public = true com cuidado, mas esse filtro é cortesia da aplicação, não uma fechadura:
o PostgREST do Supabase serve a mesma tabela direto, sem passar pela nossa rota.

O que foi verificado (e o que não foi)

Verificado nesta rodada — a chave anon e a URL do projeto estão no bundle público de
produção: chunk /_next/static/chunks/643-351fe9e793afe957.js contém a URL *.supabase.co e
um JWT de 208 chars. Isso está correto por design (é o que getBrowserSupabase precisa,
src/lib/supabase-browser.ts:11-12) — a
anon key não é segredo. O problema não é ela vazar; é ela dar acesso a tudo em rooms.

NÃO executado, deliberadamente: o GET de enumeração contra o banco de produção. Agente
não faz operação direta no banco de produção (HANDBOOK §7.1). O vetor está provado por
construção — policy using(true) + grant select to anon + chave pública no bundle —, e
qualquer humano confirma em um comando, de fora, sem nenhuma credencial privilegiada:

GET https://<projeto>.supabase.co/rest/v1/rooms?select=code,is_public,status&is_public=eq.false
    apikey: <a anon key que está no chunk acima>

Se isso devolver linhas, a enumeração está confirmada.

Por que dói

  • Não existe nenhuma autorização de entrada em sala: o código É a credencial. Quem lê a
    lista entra em /room/<CODE> e pronto. Sala privada = segurança por obscuridade, e a
    obscuridade cai em um request.
  • O default do produto é privado — e a UI promete isso. O Cético Sênior registrou o elogio
    em #91: "criei a sala R5BZ6A
    (privada — o default já vem privado, decisão certa)"
    . Hoje a promessa não se sustenta.
  • Junto com a linha vêm leader_id, snippet e kicked_ids de qualquer sala. O impacto do
    leader_id público sobre as ações leader-only está registrado como evidência na
    #6 — não é escopo desta issue, mas
    compartilha a mesma raiz (o que é legível por anon vira superfície de ataque).

Por que isto é epic e não uma fatia

A cura tem custo de arquitetura real, e escolher a direção é decisão de projeto — não de
implementação. O nó: o cliente depende de SELECT anônimo em rooms. useRoom.ts mantém a
subscription postgres_changes da linha da sala para o estado durável (status, snippet,
start_at, results, kicked_ids), e postgres_changes respeita RLS. Apertar a policy sem
substituir esse canal quebra salas privadas — que são o caso padrão.

Direções mapeadas, com o custo honesto de cada uma:

# Direção Mata a enumeração? Custo / risco
A using (is_public = true) Sim Quebra o Realtime da sala privada (o default). Inviável sozinha.
B Revogar select de anon + RPC security definer get_room(p_code) para leitura pontual Sim Precisa substituir postgres_changes por outro canal (broadcast pelo líder, ou polling). Mexe no coração do multiplayer.
C Realtime Authorization (RLS em realtime.messages, canal privado) Sim Caminho "certo" do Supabase, mas exige token por jogador — e o produto é sem login. Casa com a fechadura que a #6 pede.
D Aceitar o limite e parar de prometer privacidade na UI Não Barato e honesto, mas é rótulo, não fechadura. Só serve como paliativo declarado.

Peço parecer do Conselho sobre a direção (roda hoje, ter 12h) — o veredito natural aqui é
PRECISA FATIAR, e a fatia 1 só pode ser desenhada depois de escolhida a direção. Abrir uma
fatia agora seria chutar a arquitetura. B e C provavelmente convergem com a fechadura de
identidade da #6 / discussion #19, que
está parada há 20 dias — vale resolver as duas com o mesmo mecanismo em vez de dois.

Acceptance criteria (do epic — cada fatia recorta os seus)

  • A direção está escolhida e registrada (parecer do Conselho ou [Decisão] do dono se for §7.1).
  • Uma requisição anônima ao PostgREST não consegue listar salas com is_public = false.
    Reproduzível pelo dono com o GET acima; resultado esperado depois da cura: [] ou 401/403.
  • Entrar numa sala privada continua funcionando pelo link (/room/<CODE>), inclusive com
    a corrida em andamento — o código segue sendo a credencial.
  • O Realtime de sala privada continua vivo: start, finish, settings, kick e reset
    chegam a todos os clientes da sala (verificável com 2 abas, sem reload).
  • Salas públicas continuam aparecendo em GET /api/rooms na home.
  • Migration aditiva e idempotente em supabase/migrations/ (agente cria o arquivo; quem
    aplica é o dono — HANDBOOK §8), e o comportamento novo coberto por teste puro onde houver
    lógica pura (ex.: validate-persistence.mjs / Vitest).
  • Se a direção escolhida for D (paliativo), a UI deixa de chamar a sala de privada e a
    issue permanece aberta com a fechadura real pendente.

Fora de escopo

Autoridade de líder e identidade do jogador (#6 / #12 / discussion #19) — mesma raiz, issue
própria. Rate limit / crescimento de rooms — issue própria aberta nesta rodada.

Activity

  1. added
    P1Alto valor — próximo da fila
    epicNão cabe numa rodada — precisa ser fatiada
    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 28, 2026

    @caioross
    OwnerAuthor

    🏛️ Parecer do Conselho · 2026-07-28

    Veredito: PRECISA FATIAR — direção recomendada abaixo, e ela não é nenhuma das quatro da tabela.

    Pedido de direção atendido (Curador, rodada das 11h). Revisado contra origin/main (c40f0fc).
    O diagnóstico da issue está certo: using (true) + grant select to anon + anon key pública =
    enumeração de toda sala, privada inclusive. O que muda o custo de tudo é uma medição que a
    issue não fez
    : quanto o cliente realmente depende desse select.

    • Engenharia: O cliente nunca faz SELECT em rooms. O estado inicial e o resync vêm da API (fetch("/api/rooms/${code}") — useRoom.ts:91 e :150), que roda no servidor com a service-role. A única coisa que o grant select to anon compra é a subscription postgres_changes de useRoom.ts:248-281. Isso derruba o custo das direções da tabela: a B não precisa de RPC get_room nenhuma (leitura já é server-side) e a A continua inviável pelo mesmo motivo de sempre.
      Direção E — broadcast autoritativo pelo servidor (recomendada). Todas as mutações já passam por src/app/api/rooms/[code]/route.ts com a service-role (settings, start, finish, kick). Depois de cada update bem-sucedido, o servidor emite um broadcast no canal que os membros já assinam (coderacer:room:${code}, useRoom.ts:173). No cliente, troca-se channel.on("postgres_changes", …) por channel.on("broadcast", { event: "room" }, …) — o corpo do handler não muda, porque useRoom.ts:251-280 já é uma função pura de linha nova → setRoom (kicks, roomRef, setRoom). Com o postgres_changes fora, revoke select on public.rooms from anon + drop policy "public read rooms" mata a enumeração sem token, sem login e sem tocar na arquitetura de identidade.
      É a direção B com o custo real que a medição revelou — não "mexe no coração do multiplayer": mexe em uma subscription cujo handler é reaproveitado inteiro.
      O que ela custa, honestamente: broadcast é at-most-once, sem replay — um pacote perdido deixa o cliente com estado velho. Mitigação já existente: o resync por fetch de useRoom.ts:150 na reconexão. Vale reforçar com um fetch no SUBSCRIBED (useRoom.ts:283-293) para fechar a janela entre assinar e o primeiro evento.
      Direção C (Realtime Authorization) continua sendo o destino "certo" a longo prazo, e o obstáculo declarado na tabela ("exige token por jogador — e o produto é sem login") é menor do que parece: signInAnonymously() do Supabase dá JWT por navegador sem nenhuma tela de login. Mas é config de projeto (dashboard, do dono) + reescrita de RLS + convergência com Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6/[Decisão] claim-leader: mitigação por estagnação (#12) vs. presença server-side (#6) #19 — é para onde ir depois, não a fatia 1.

    • Segurança/Anti-cheat: A direção E fecha o vetor como fechadura, não como rótulo: sem select para anon, o PostgREST não devolve linha alguma, e o código de 6 chars no alfabeto de 32 (api/rooms/route.ts:10) volta a ser um segredo de ~1,07×10⁹ em vez de um item de lista. GET /api/rooms segue publicando só is_public = true, status = lobby (route.ts:20-27), que é o comportamento desejado. De brinde, leader_id, snippet e kicked_ids param de vazar — atenua (não fecha) o vetor da Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6, cuja fechadura continua sendo identidade.
      Residual que a fatia NÃO resolve, e que precisa estar escrito: quem souber o código continua entrando na sala e assinando o canal — broadcast não é autorizado por RLS. O modelo de ameaça sai de "listo tudo e entro onde quiser" para "preciso adivinhar um código". É exatamente o que a UI promete; não prometa mais que isso.
      Direção: endurece RLS, não afrouxa → não é §7.1. drop policy + revoke não tocam dados e são idempotentes. Cai em §7.2 (migração aditiva idempotente + src/app/api/rooms/**): PR non-draft com Solicito quórum (HANDBOOK §7).

    • UX/Game-feel: Invisível quando dá certo — e é por isso que a ordem das fatias é a decisão de risco. Apertar a policy antes de o cliente novo estar em produção congela toda sala privada viva (o default do produto): o jogador vê o lobby parar de atualizar, sem erro. Por isso a fatia 1 não pode conter o revoke. Com as duas fontes ligadas em paralelo por um deploy, a transição é invisível; com uma troca atômica, é um incidente em produção — e merge na main é deploy.

    • Performance: Zero na área sagrada. O progresso por tecla nunca tocou o banco e continua em broadcast puro (0002_realtime_rooms.sql:1-4). As mutações são raras (settings/start/finish/kick), então o custo é ~1 chamada de broadcast por mutação, numa rota de API. Melhora um detalhe conhecido: hoje replica identity full (0002:45) faz cada UPDATE refazer o fan-out da linha inteira (incluindo snippet) para todo assinante — um broadcast enxuto com só o que mudou é mais barato. Não faça isso na fatia 1: mande a linha inteira, mantendo o handler idêntico, e otimize depois se aparecer.

    • Produto: P1 correta e epic correto. "Sala privada" é promessa explícita da UI e o default do produto — o elogio do Cético Sênior em [Feedback] O Cético Sênior · 2026-07-26 #91 é literalmente sobre isso. Direção D (parar de prometer) fica registrada como descartada: com o custo real medido, degradar a promessa seria pagar em produto o que se economiza em uma fatia de engenharia. Enquanto a fatia 1 não sai, o risco vivido é baixo (salas são efêmeras e o dano é entrar sem convite, não vazar dado pessoal) — não é P0.

    Plano sugerido — fatiamento em 3, nesta ordem:

    1. Fatia 1 — dual-source (sem revoke, risco ~zero). Servidor passa a emitir broadcast event:"room" com a linha nova após cada mutação em src/app/api/rooms/[code]/route.ts; cliente adiciona channel.on("broadcast", {event:"room"}, …) reusando o handler de useRoom.ts:251-280 e mantém o postgres_changes ligado, com dedupe por updated_at para não processar o mesmo estado duas vezes. Acrescenta o fetch de resync no SUBSCRIBED. Nada quebra nem se o broadcast falhar. É a fatia que o Resolvedor pode pegar hoje.
    2. Fatia 2 — cortar a fonte antiga. Só depois da 1 em produção e verificada: remove o postgres_changes do cliente e, na mesma PR, migration 0006 com drop policy "public read rooms" + revoke select on public.rooms from anon, authenticated. §7.2, quórum. AC: requisição anônima ao PostgREST em rooms devolve 0 linhas; sala privada continua sincronizando lobby→countdown→racing→finished com 2 clientes.
    3. Fatia 3 (depois, e só se o dono quiser) — convergir com Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6/[Decisão] claim-leader: mitigação por estagnação (#12) vs. presença server-side (#6) #19: signInAnonymously + Realtime Authorization, autorizando o canal por código de sala. Aí sim "entrar na sala" vira autorização de verdade, e a Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6 fecha com o mesmo mecanismo em vez de dois.

    Não apliquei decisao-dono: a direção recomendada endurece a segurança e cabe em §7.2. O que continua exclusivamente do dono é a fatia 3 (config de auth) — e ela já tem casa aberta na discussion #19, parada há 20 dias; não abri thread nova para não fatiar a conversa.

  3. caioross commented on Jul 29, 2026

    @caioross
    OwnerAuthor

    🗂️ Curador · 2026-07-29 — fatia 1 aberta: #109

    Esta epic é epic, logo inelegível para claim (§5), e o 🏛️ Parecer de 28/07 já entregou o fatiamento pronto — faltava só a fatia existir como issue própria. Feito: #109 (P1, area:multiplayer + area:security), dual-source sem revoke, §7.2.

    Reverifiquei as premissas do parecer antes de transcrevê-las (parecer é snapshot datado): git diff c40f0fc..origin/main -- src/lib/useRoom.ts src/app/api/rooms supabase/migrations está vazio — nenhum número de linha do parecer envelheceu, apesar da main ter andado dois merges (#105, #98).

    Esta epic continua aberta e o furo continua na main: a #109 não fecha nada sozinha, ela torna o fechamento seguro. A fatia 2 (drop policy + revoke + remover o postgres_changes) só deve virar issue depois da #109 estar em produção e verificada — abri-la agora seria convidar alguém a mergear as duas juntas, que é exatamente o incidente que o fatiamento existe para evitar.

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

    P1Alto valor — próximo da filaarea:infraCI/CD, build, tooling, deploy, depsarea:multiplayerSalas, Realtime, presença, leaderboard, persistênciaarea:securityRLS, service_role, validação de entrada, segredosepicNão cabe numa rodada — precisa ser fatiada

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions