Laudo de segurança · prova de conceito

Anatomia
de um
vazamento

Como os dados de clientes do Elegance Space e do Maya Massoterapia ficaram acessíveis sem nenhum login — passo a passo, e como fechar.

O login não era a segurança. Quem protege os dados é o RLS no banco — e ele estava desligado. Ninguém arromba a porta: contorna por trás.
role para investigar
01 · interativo

Ligue o RLS e veja a falha fechar

Este é o fluxo real de acesso. O RLS é o único elo que decide se o banco vaza — mexa no botão e acompanhe o banco trancar.

simulação · fluxo de acesso
RLS OFF
Visitante qualquer um Login 🔒 tela de entrada tenta a porta barrado API REST chave pública contorna pela API banco
clique (ou toque) no botão RLS  ·  use ↑ ↓ para navegar os slides
02 · O elenco

Três sites, um banco só

Os três projetos compartilham o mesmo backend Supabase e a mesma chave pública. Expor um expõe a superfície de todos.

Site 01 · salão

Elegance Space

Agendamento de unhas.

BANCO ABERTO
Site 02 · massoterapia

Maya

Agendamento e pacotes.

BANCO ABERTO
Site 03 · portal

LV Solutions

Painel administrativo.

TRANCADO

O LV Solutions prova que dá para fazer certo no mesmo banco — a diferença está numa única linha. Esta apresentação mostra o que faltou nos outros dois.

03 · A ideia central

A chave é pública de propósito

Todo app Supabase embarca uma chave anon no JavaScript que o navegador baixa. Isso é normal — ela nasceu para ser pública.

O que impede essa chave de ler o banco inteiro é o RLS: uma trava, por tabela, que diz “quem pode ver qual linha”. Sem RLS, a chave pública abre tudo.

Pense no RLS como a fechadura do cofre. A chave pública todo mundo tem. O cofre só fica seguro se estiver trancado.
o que qualquer visitante já tem
// dentro do site, à vista
URL  qjbkl…vjb.supabase.co
key  sb_publishable_f-GB••••08
valores reais mascarados aqui
01
Passo 1

O login é uma fachada

A página de login é desenhada pelo próprio navegador (é um app JavaScript). Nenhum dado real mora nela — ela só conversa com o backend. Traduzindo: o atacante nem tenta o login. Ele mira o backend direto.

Tentar “adivinhar a senha” seria o caminho errado — e o mais difícil. O caminho fácil é outro.

02
Passo 2

Descobrir que é Supabase

Basta olhar o código que o site entrega. O próprio navegador mostra as conexões para *.supabase.co. Confirmado o alvo: um banco Supabase por trás do site.

navegador · aba Network
# conexões de saída do site
https://qjbkl…vjb.supabase.co 200
# → o backend é Supabase
03
Passo 3

Pegar a URL e a chave no bundle

Elas ficam dentro do JavaScript que o site entrega a todo visitante. Abrir F12 → Network, achar o index-….js e procurar por supabase.co e sb_publishable_.

assets/index-….js
const SUPABASE_URL = "https://qjbkl…vjb.supabase.co"
const SUPABASE_KEY = "sb_publishable_f-GB••••••••••08"
chave mascarada aqui por segurança — no site real ela aparece inteira
04
Passo 4

Descobrir os nomes das tabelas

Também estão no mesmo JavaScript, nas chamadas .from("tabela"). Foi assim que apareceram todas:

Elegance · 6 tabelas
appointments · blocked_clients
professionals · services
weekly_schedule · schedule_blocks
Maya · 10 tabelas
maya_appointments · maya_clients
maya_client_packages · maya_services
maya_package_sessions · +5
05
Passo 5 · o momento

Falar direto com o banco, pulando o login

O Supabase expõe cada tabela numa URL. Basta enviar a chave pública no cabeçalho — sem usuário, sem senha, sem login.

PowerShell — a mesma linha que você rodou
PS>
a chave está mascarada — no comando real ela é a que veio do bundle
06
Passo 6 · a diferença exata

Aberto vs fechado

A resposta do banco conta tudo. É literalmente a diferença entre um site vulnerável e um site seguro — no mesmo servidor.

Elegance / Maya200
GET /rest/v1/maya_clients apikey: sb_publishable_… ← devolve as linhas: [ { "client_name": "…", "phone": "…" }, … ]
RLS DESLIGADO · vaza
LV Solutions401
GET /rest/v1/lv_projects apikey: sb_publishable_… ← barrado: { "message": "permission denied" }
RLS LIGADO · protegido
10 · A causa-raiz

Um único elo falhou

Nenhum dos passos 1 a 4 é “falha” — a chave é pública mesmo. O que abriu tudo foi a camada final.

CamadaEstado hojeDeveria ser
Chave pública no JSnormal ✓normal (é público)
Tela de loginexiste, mas irrelevante
RLS nas tabelasdesligado 🔴ligado + políticas
Permissão do papel anonlê, altera e apaga tudosó o mínimo público

Com o RLS ligado, o Passo 5 já devolve 401. Os passos 1–4 continuam possíveis — e não levam a lugar nenhum.

11 · O impacto

O que estava ao alcance de qualquer um

pessoas expostas
0
nome, telefone e e-mail reais, entre os dois negócios
agendamentos Elegance
0
e crescendo — 3 novos durante a análise
tabelas abertas
0
leitura e escrita, sem login
senhas quebradas
0
nenhuma. Não precisou — o login foi contornado
Isso é dado pessoal de gente real — e massoterapia é dado de saúde. Tem peso de LGPD, não é só um bug técnico.
12 · O conserto

Uma linha por tabela estanca na hora

Ligar o RLS. Sem política, o banco passa a negar tudo por padrão — o vazamento fecha imediatamente. Depois você reabre só o mínimo público, com calma.

alter table public.appointments enable row level security; alter table public.maya_appointments enable row level security; -- … e as outras 14 tabelas
  • Ligar RLS nas 16 tabelas — fecha o Passo 5 na hora.
  • Reabrir só o público (serviços, horários) por política — nunca appointments/clients.
  • Verificar: repetir o Passo 5 → tem que virar 401. O script checar_vazamento.py testa as 16 de uma vez.
Enquanto uma tabela aparecer como EXPOSTO, ainda está aberta. Quando virar PROTEGIDO, fechou de verdade.