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

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
    #6nã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.

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