Herramienta de enumeración de schema y columnas para proyectos Supabase mediante técnicas de error-based enumeration sobre la API REST de PostgREST.
Descubre tablas ocultas, verifica si RLS está configurado correctamente, y enumera nombres de columnas — todo sin autenticación y sin acceso al código fuente.
supabase/
supabase_enum.py # Script principal
README.md # Este archivo
wordlists/
tables_common.txt # ~120 nombres genéricos de tablas
tables_business.txt # ~100 nombres orientados a negocios y e-commerce
columns_common.txt # ~100 nombres comunes de columnas
# Única dependencia externa
pip3 install requests
# Dar permisos de ejecución (opcional)
chmod +x supabase_enum.py# Mínimo — usa wordlists por defecto
python3 supabase_enum.py <url> <anon_key>
# Con wordlist de tablas específica
python3 supabase_enum.py <url> <key> -w wordlists/tables_business.txt
# Con enumeración de columnas
python3 supabase_enum.py <url> <key> --columnas
# Columnas con wordlist personalizada
python3 supabase_enum.py <url> <key> --columnas -wc wordlists/columns_common.txt
# Más velocidad
python3 supabase_enum.py <url> <key> -t 20 -r 20
# Sin fase de consulta detallada
python3 supabase_enum.py <url> <key> --no-detalle
# La URL puede incluir /rest/v1 o no — ambas formas funcionan
python3 supabase_enum.py https://abc123.supabase.co <key>
python3 supabase_enum.py https://abc123.supabase.co/rest/v1 <key>| Flag | Default | Descripción |
|---|---|---|
-w / --wordlist |
wordlists/tables_common.txt |
Wordlist de nombres de tablas |
-wc / --wordlist-cols |
wordlists/columns_common.txt |
Wordlist de nombres de columnas |
-t / --threads |
10 |
Threads concurrentes |
-r / --rate |
10 |
Requests por segundo |
--columnas |
off | Activar enumeración de columnas |
--no-detalle |
off | Saltar fase 3 (consulta detallada) |
El script corre hasta cuatro fases en secuencia:
PostgREST tiene un comportamiento particular: cuando pedís una tabla que no existe, devuelve un campo hint sugiriendo la tabla más parecida fonéticamente (distancia de Levenshtein).
GET /rest/v1/customers → 404
{
"code": "PGRST205",
"hint": "Perhaps you meant the table 'public.business_users'"
^^^^^^^^^^^^^^
tabla real descubierta
}
El script itera la wordlist, filtra las respuestas 404 que contienen hints, y extrae los nombres de tablas reales sugeridos — incluso si esos nombres no estaban en tu wordlist original.
Clave técnica: las respuestas con hints son HTTP 404, no 200. Herramientas como ffuf las descartan por defecto a menos que se use -mc 404 explícitamente. Este script las captura siempre.
Consulta cada tabla directamente y evalúa la respuesta:
| Respuesta | Interpretación |
|---|---|
[{...datos...}] |
🔴 Sin RLS — datos reales expuestos |
[] |
🟡 Ambiguo — vacía O RLS silenciando (no distinguible sin auth) |
PGRST301 |
✅ RLS activo — acceso denegado explícito |
⚠️ Nota sobre[]: Supabase silencia los errores de RLS devolviendo[]en lugar de un 403. Una tabla con datos protegidos y una tabla vacía se ven igual desde afuera. El script refleja esta ambigüedad honestamente en el output.
Para cada tabla descubierta, hace una consulta con limit=3&order=id.desc para ver si hay datos reales expuestos y muestra un preview del JSON.
Descubre los nombres de columnas reales de cada tabla mediante INSERTs con columnas inventadas. Los distintos errores de PostgreSQL revelan si la columna existe o no:
| Error recibido | Significado |
|---|---|
PGRST204 — "column not found" |
Columna no existe → ignorar |
42501 — "row-level security policy" |
Columna sí existe (RLS bloqueó el INSERT) |
23xxx — constraint violation |
Columna sí existe (violó una restricción) |
HTTP 201 — INSERT exitoso |
Columna sí existe + sin RLS en escritura 🔴 |
Corre en todas las tablas descubiertas en fases 1 y 2 combinadas.
+------------------------------------------+
| supabase_enum.py |
| PostgREST schema enumeration tool |
+------------------------------------------+
Target: https://proyecto.supabase.co/rest/v1
Wordlist tablas: wordlists/tables_business.txt (98 palabras)
Wordlist cols: wordlists/columns_common.txt (102 palabras)
Threads: 10
Rate: 10 req/s
[*] Verificando target...
[+] PostgREST confirmado
[*] Fase 1 - Error-based enumeration (buscando hints)
-> business_users (via hint de 'customers')
-> order_items (via hint de 'items')
Progreso: 98/98 - completado
[+] 2 tabla(s) descubiertas via hints
[*] Fase 2 - Tablas accesibles sin autenticacion
[~] orders -> responde [] (vacia o RLS silenciando)
[~] business_users -> responde [] (vacia o RLS silenciando)
Progreso: 100/100 - completado
[~] 2 tabla(s) con respuesta ambigua ([])
[*] Fase extra - Enumeracion de columnas
-> orders
-> customer_phone (existe)
-> status (existe)
-> created_at (existe)
-> total (existe, constraint)
Progreso: 102/102 - completado
4 columna(s) encontradas: created_at, customer_phone, status, total
==========================================
RESUMEN
==========================================
TABLAS DESCUBIERTAS
business_users - hint via 'customers', responde []
order_items - hint via 'items', responde []
orders - responde []
products - responde []
ESTADO DE RLS
[+] Sin tablas con datos expuestos directamente
[~] Responden [] (ambiguo - vacia o RLS silenciando):
business_users
order_items
orders
products
COLUMNAS DESCUBIERTAS
business_users - sin columnas encontradas
orders
created_at existe
customer_phone existe
status existe
total existe (constraint)
==========================================
Las credenciales siempre están en el JavaScript del frontend porque el cliente se inicializa en el browser.
Firefox / Chrome → F12 → Debugger → Search (Ctrl+Shift+F)
Buscar: supabase.co
También buscar: createClient(, SUPABASE_URL, NEXT_PUBLIC_SUPABASE
# Extraer URLs de chunks JS de la página principal
curl -s https://TARGET.com | grep -oE '/_next/static/chunks/[^"]+\.js' | sort -u
# Buscar credenciales en cada chunk
for chunk in $(curl -s https://TARGET.com | grep -oE '/_next/static/chunks/[^"]+\.js'); do
resultado=$(curl -s "https://TARGET.com$chunk" | grep -o '"https://[a-z0-9]*.supabase\.co"')
[ -n "$resultado" ] && echo "$chunk: $resultado"
doneNext.js expone en el bundle todo lo que tenga prefijo NEXT_PUBLIC_:
curl -s https://TARGET.com/_next/static/chunks/main*.js | \
grep -oE 'NEXT_PUBLIC_[A-Z_]+":"[^"]*"'# URL del proyecto
grep -oE '"https://[a-z0-9]{20}\.supabase\.co"' bundle.js
# Anon key formato nuevo
grep -oE '"sb_publishable_[a-zA-Z0-9_-]+"' bundle.js
# Anon key formato JWT (empieza con eyJ)
grep -oE '"eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+"' bundle.js- Texto en la UI: "Conectá Supabase", "seed inicial", "Supabase project"
- Requests en el proxy a
*.supabase.co/rest/v1/o*.supabase.co/realtime/ - Variables
NEXT_PUBLIC_SUPABASE_URLen el bundle - Errores con código
PGRSTen cualquier response de la API - Stack Next.js + Vercel (combinación muy frecuente con Supabase)
| Situación | HTTP | Body | Notas |
|---|---|---|---|
| Tabla existe y accesible | 200 | [] o [{...}] |
|
| Tabla no existe con hint | 404 | {"hint":"Perhaps..."} |
ffuf lo descarta si no usás -mc 404 |
| RLS bloquea SELECT | 200 | [] silencioso |
Indistinguible de tabla vacía |
| RLS bloquea INSERT | 403 | {"code":"42501"} |
Confirma que la columna existe |
| Constraint en INSERT | 400/409 | {"code":"23xxx"} |
Confirma que la columna existe |
| Key inválida o revocada | 401 | {"message":"Invalid API key"} |
Si encontrás tablas expuestas, el fix en Supabase es:
-- 1. Activar RLS en cada tabla sensible
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE business_users ENABLE ROW LEVEL SECURITY;
-- 2. Denegar acceso anónimo por defecto
CREATE POLICY "deny anon"
ON orders FOR ALL TO anon
USING (false);
-- 3. Si la tabla debe ser pública (ej: productos del menú)
CREATE POLICY "public read products"
ON products FOR SELECT TO anon
USING (true);
-- 4. Solo usuarios autenticados (ej: orders, users)
CREATE POLICY "authenticated only"
ON orders FOR SELECT TO authenticated
USING (true);| BaaS | Qué buscar en el bundle | Qué testear |
|---|---|---|
| Firebase | firebaseapp.com, apiKey: |
Reglas de Firestore / RTDB |
| Appwrite | appwrite.io, new Client() |
/v1/databases/<id>/collections |
| PocketBase | URL custom, pb.authStore |
/api/collections/<n>/records |
| Directus | URL custom, createDirectus( |
/items/<collection> |
| Hasura | hasura.io, x-hasura-admin-secret |
GraphQL introspection |
- Usar únicamente en aplicaciones propias o con permiso explícito y por escrito del dueño
- Limitar el rate a ≤ 10 req/s para no afectar el servicio en producción
- El tier gratuito de Supabase tiene límites de requests — no spamear
- Reportar los hallazgos al desarrollador de forma responsable antes de hacerlos públicos
- Esta herramienta es para auditoría de seguridad, no para acceso no autorizado a datos ajenos