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)
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.
Problema — "sala privada" não é privada: qualquer um lista todas as salas
A tabela
roomsé legível poranonsem nenhum filtro, e a chave anon está publicadano bundle de produção. Resultado:
is_public = falsenão esconde nada — dá para enumerartodas 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):using (true)= qualquer linha, para qualquer um. A rotasrc/app/api/rooms/route.ts:22-24filtrais_public = truecom 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.jscontém a URL*.supabase.coeum JWT de 208 chars. Isso está correto por design (é o que
getBrowserSupabaseprecisa,src/lib/supabase-browser.ts:11-12) — aanon key não é segredo. O problema não é ela vazar; é ela dar acesso a tudo em
rooms.NÃO executado, deliberadamente: o
GETde enumeração contra o banco de produção. Agentenã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 —, equalquer humano confirma em um comando, de fora, sem nenhuma credencial privilegiada:
Se isso devolver linhas, a enumeração está confirmada.
Por que dói
lista entra em
/room/<CODE>e pronto. Sala privada = segurança por obscuridade, e aobscuridade cai em um request.
em #91: "criei a sala
R5BZ6A(privada — o default já vem privado, decisão certa)". Hoje a promessa não se sustenta.
leader_id,snippetekicked_idsde qualquer sala. O impacto doleader_idpú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 é
epice não uma fatiaA 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.tsmantém asubscription
postgres_changesda linha da sala para o estado durável (status, snippet,start_at, results, kicked_ids), e
postgres_changesrespeita RLS. Apertar a policy semsubstituir esse canal quebra salas privadas — que são o caso padrão.
Direções mapeadas, com o custo honesto de cada uma:
using (is_public = true)selectdeanon+ RPCsecurity definerget_room(p_code)para leitura pontualpostgres_changespor outro canal (broadcast pelo líder, ou polling). Mexe no coração do multiplayer.realtime.messages, canal privado)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 umafatia 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)
is_public = false.Reproduzível pelo dono com o
GETacima; resultado esperado depois da cura:[]ou 401/403./room/<CODE>), inclusive coma corrida em andamento — o código segue sendo a credencial.
start,finish,settings,kickeresetchegam a todos os clientes da sala (verificável com 2 abas, sem reload).
GET /api/roomsna home.supabase/migrations/(agente cria o arquivo; quemaplica é o dono — HANDBOOK §8), e o comportamento novo coberto por teste puro onde houver
lógica pura (ex.:
validate-persistence.mjs/ Vitest).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.