Novedades Artículo
ArtículoAislar tenants en Supabase con RLS: cinco fugas reales que encontramos en nuestro propio código
Armazón es un conjunto de 16 paquetes de backend multi-tenant para Supabase. Cada paquete se verifica con pgTAP contra un Supabase real y, cada vez que esa verificación encuentra un fallo de seguridad de verdad, lo apuntamos en un registro de incidentes. Este artículo cuenta cinco de las siete entradas que tiene hoy.
No es una lista de «errores que comete la gente». Son fallos nuestros, en código escrito con cuidado y revisado. Precisamente por eso sirven: si te pasan a ti con el código bien hecho, te pasan a ti.
Para cada uno: qué estaba expuesto, cómo lo encontramos y qué comprobar en tu proyecto.
1. revoke execute … from anon no revoca nada
Qué pasó. Tres funciones de auth-core que debían ser solo para usuarios con sesión (request_email_verification, get_mfa_status y can_manage_credentials) seguían siendo ejecutables por anon. La migración que debía cerrarlo hacía esto:
revoke execute on function public.get_mfa_status() from anon;
Y su propio comentario afirmaba que con eso bastaba. No bastaba.
Por qué. Postgres concede EXECUTE sobre una función nueva al pseudo-rol PUBLIC, y todos los roles heredan de él. Quitarle el permiso solo a anon no toca el grant de PUBLIC. El patrón correcto es revocar de public y conceder explícitamente a quien lo necesita:
revoke execute on function public.get_mfa_status() from public;
grant execute on function public.get_mfa_status() to authenticated;
Cómo lo encontramos. Con el advisor de seguridad de Supabase, justo después de aplicar la migración al proyecto real. Ningún pgTAP lo detectó, porque ningún test comprobaba privilegios de ejecución. Ahora hay uno que lo hace:
select ok(
not has_function_privilege('anon', 'public.get_mfa_status()', 'execute'),
'anon cannot call get_mfa_status'
);
Qué comprobar en tu proyecto. Una lista de las funciones de public que anon puede ejecutar:
select p.oid::regprocedure as funcion
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and has_function_privilege('anon', p.oid, 'execute');
Cada fila debería ser una decisión consciente, no un descuido.
2. Una vista que se salta la RLS de su propia tabla
Qué pasó. En consent-core, la vista current_consents devuelve el último consentimiento por sujeto y tipo. Por defecto, una vista de Postgres se ejecuta con los privilegios de quien la creó, no de quien la consulta. Creada por el propietario, habría devuelto los consentimientos de todos los tenants a cualquiera, ignorando la RLS de la tabla de debajo.
Cómo lo evitamos. La vista se creó desde el principio con security_invoker = true:
create view public.current_consents
with (security_invoker = true)
as
select distinct on (tenant_id, coalesce(subject_user_id::text, subject_email), consent_type)
tenant_id, subject_user_id, subject_email, consent_type, status, recorded_at
from public.consent_records
order by tenant_id, coalesce(subject_user_id::text, subject_email), consent_type,
recorded_at desc, sequence_id desc;
Lo detectamos en una revisión durante la construcción del paquete, no tras un incidente. Lo incluimos porque es un fallo fácil de cometer con las vistas «de conveniencia».
Qué comprobar en tu proyecto.
select c.relname, c.reloptions
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'v';
Las vistas que lean tablas con RLS deberían mostrar security_invoker=true en reloptions.
3. Una política de Storage que solo miraba el prefijo del tenant
Qué pasó. En tenant-storage, la tabla de metadatos de documentos exigía el permiso documents.read. La política de storage.objects, en cambio, solo comprobaba que la carpeta de la ruta coincidiera con el tenant del usuario. Un miembro del staff sin permiso no veía nada en la tabla, pero podía listar nombres y rutas de los ficheros de su propio negocio llamando directamente a la API de Storage.
Cómo lo arreglamos. Igualando las dos políticas:
create policy tenant_documents_storage_select on storage.objects
for select
using (
bucket_id = 'tenant-documents'
and (
public.is_platform_admin()
or (
(storage.foldername(name))[1] = public.jwt_tenant_id()::text
and public.has_permission('documents.read')
)
)
);
Cómo lo encontramos. Escribiendo el pgTAP que le faltaba al paquete. Hasta ese momento solo había verificación manual.
Lección. Si una regla de acceso vive en dos sitios (la tabla y el bucket), son dos reglas, y alguien las va a desincronizar.
4. RLS «activada» que no lo estaba
Qué pasó. En notifications, la migración ejecutaba ENABLE ROW LEVEL SECURITY en tres tablas y la herramienta de aplicar migraciones reportó éxito. Al comprobarlo, pg_class.relrowsecurity era false en las tres: las políticas existían pero no se aplicaban.
No sabemos cuál fue la causa. Lo documentamos tal cual: origen sin confirmar, arreglo idempotente (repetir las mismas sentencias) y una regla nueva para todas las migraciones posteriores: comprobar el estado real justo después de activar la RLS.
select relname, relrowsecurity
from pg_class
where relname in ('notification_preferences', 'notification_templates', 'notification_log');
Qué comprobar en tu proyecto.
select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' and not c.relrowsecurity;
Toda tabla que aparezca debería poder explicarse.
5. Una función security definer que confiaba en el tenant_id que le pasaban
Qué pasó. La primera versión de dispatch_webhook_event(p_tenant_id, …) en webhooks-core no comprobaba que el tenant_id recibido fuera el del llamador. Cualquier usuario autenticado podría haber encolado entregas falsas contra el webhook real de otro negocio.
Lo vimos en una autorevisión durante la construcción del paquete. Como mucho estuvo así en nuestro proyecto de pruebas el tiempo entre dos migraciones consecutivas. La versión final exige, en llamadas directas, que el tenant del JWT coincida:
if pg_trigger_depth() = 0
and not (public.is_platform_admin() or public.jwt_tenant_id() = p_tenant_id) then
raise exception 'No autorizado para despachar eventos de este negocio';
end if;
Dentro de una cadena de triggers la comprobación se salta a propósito: ahí el JWT sigue siendo el del llamador original (por ejemplo, un visitante anónimo que envía un formulario) y el tenant_id ya sale de una fila validada. Es una excepción con riesgo propio, y por eso está comentada y probada.
Regla general. En una función security definer expuesta, un parámetro tenant_id es entrada del atacante hasta que se demuestre lo contrario.
Lo que esto no demuestra
Cinco hallazgos corregidos no significan «cero fallos». Significan que el proceso (pgTAP, verificación contra un Supabase real y registro honesto) encuentra cosas, y que las que encuentra quedan con un test que impide que vuelvan. Los paquetes están en versión 0.x y lo decimos. No hay auditoría externa.
Lista rápida para tu proyecto
- Funciones de
publicejecutables poranon(consulta del punto 1). - Vistas sin
security_invokerque lean tablas con RLS (punto 2). - Políticas de
storage.objectsfrente a las de la tabla que las describe (punto 3). - Tablas con
relrowsecurity = false(punto 4). - Funciones
security definercon parámetros de identidad (punto 5).
El registro completo, con las siete entradas, está en Seguridad y verificación. Si quieres ver cómo se aplica a los 16 paquetes, empieza por la lista de paquetes.