En resumen
El aviso significa que una función no tiene un search_path fijo, así que cada nombre sin calificar dentro de ella, como admins, se busca como lo indique la sesión de quien la llama. En mi prueba local, una función SECURITY DEFINER is_admin() devolvió true para un usuario sin permisos de administrador después de que ese usuario creara una tabla temporal llamada admins, y fijar search_path = public no lo evitó. Lo que funcionó: set search_path = '' con cada tabla escrita como public.admins.
El Security Advisor de Supabase lo lista como el lint 0011, “Function Search Path Mutable”, nivel WARN, con el detalle “Function public.x has a role mutable search_path”. Marca todas las funciones sin el ajuste, incluidas las SECURITY INVOKER, así que en un proyecto con muchos ayudantes la lista se alarga: un proyecto de código abierto corrigió 99 de ellas en una sola migración.
¿Qué significa “role mutable search_path”?
search_path es la lista de esquemas en la que Postgres busca un nombre que no lleva esquema delante. Una función sin ajuste propio usa la lista de quien la llama. La página del lint de Supabase recomienda fijarlo en una cadena vacía, lo que obliga a calificar por completo cada referencia dentro de la función.
Para una función SECURITY DEFINER esto importa más: se ejecuta con los permisos de su propietario, normalmente postgres, que se salta RLS. Si quien llama puede dirigir qué admins lee la función, dirige una comprobación que se ejecuta como postgres.
¿Se puede explotar en mi prueba?
En un Postgres 17.10 local creé una tabla public.admins con un administrador y cuatro versiones de la misma función, select exists (select 1 from admins where user_id = uid), todas SECURITY DEFINER. Después inicié sesión con un rol de pocos privilegios que puede ejecutar SQL y tiene el derecho por defecto a crear tablas temporales, y creé pg_temp.admins con el usuario A, que no es administrador:
| Ajuste de la función | Condición del lint | is_admin(A) tras la tabla temporal |
|---|---|---|
| ninguno | marcada | true |
set search_path = public |
pasa | true |
set search_path = '', el cuerpo usa public.admins |
pasa | false |
set search_path = public, pg_temp |
pasa | false |
La segunda línea es la que me sorprendió. Un mantenedor de Supabase escribió en 2024 que una ruta fija no vacía no conlleva riesgo de seguridad, y el lint la acepta. Pero Postgres busca primero en el esquema temporal salvo que pg_temp se nombre en la ruta, como advierte su documentación sobre cómo escribir funciones SECURITY DEFINER. Así que search_path = public sigue dejando que una tabla temporal tape un nombre sin calificar.
¿Quién puede hacerlo realmente en Supabase?
No un visitante a través de su app. La Data API no puede crear tablas temporales ni cambiar el search_path de la sesión, así que la clave publicable por sí sola no llega hasta ahí. Hace falta una conexión directa a la base de datos: un segundo servicio con su propio login, una herramienta de BI o de informes, un rol de solo lectura de un contratista, una cadena de conexión filtrada. En la mayoría de las apps pequeñas el riesgo es bajo; en cualquier proyecto donde otras personas o herramientas además de usted tengan logins de base de datos, corrija primero las funciones definer.
¿Cómo se corrige?
Recree la función con una ruta vacía y nombres de esquema en el cuerpo:
create or replace function public.is_admin(uid uuid)
returns boolean
language sql stable security definer
set search_path = ''
as $$ select exists (select 1 from public.admins where user_id = uid) $$;
revoke execute on function public.is_admin(uuid) from public, anon;
El revoke importa tanto como la ruta: una función definer en public se puede llamar a través de la Data API, así que sin él cualquier invitado puede preguntar a /rpc/is_admin si un usuario dado es administrador. Vuelva a concederlo solo a los roles que lo necesiten, o deje ayudantes como este en un esquema que la API no exponga.
No ejecute solo alter function public.is_admin(uuid) set search_path = ''. Silencia el Advisor y rompe la función: en mi ejecución la siguiente llamada falló con 42P01: relation "admins" does not exist. Los operadores de extensiones también necesitan su esquema, como operator(extensions.<=>) para pgvector.
Para listar lo que el lint marcará en public, con las funciones definer primero:
select p.proname, p.prosecdef as security_definer
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and not exists (select 1 from unnest(coalesce(p.proconfig, '{}')) c
where c like 'search_path=%')
order by p.prosecdef desc, p.proname;
¿Tiene algún coste la corrección?
A veces. Cualquier cláusula set impide que Postgres incorpore una función SQL simple a la consulta que la llama, como se informó en GitHub. Medí ambas formas con 200.000 filas y un índice en la columna filtrada (mediana de 7 ejecuciones):
| Función SQL, luego filtrada por propietario | Sin set |
set search_path = '' |
|---|---|---|
Filtro fuera: visible_items() where owner = ... |
0,053 ms, usa índice | 36,7 ms, lectura completa |
Filtro dentro: items_of(owner) |
0,056 ms | 0,082 ms |
Si una función SQL que devuelve un conjunto se consulta con un filtro fuera de ella, mueva el filtro dentro como parámetro antes de fijar la ruta.
Compruebe su proyecto
- En el Advisor, corrija primero las funciones
SECURITY DEFINERde la lista; las funciones invoker se ejecutan con los permisos de quien llama de todos modos. - El verificador de seguridad de Supabase, gratuito, muestra los hallazgos del Advisor junto a sus propias reglas, incluidas las funciones definer invocables por la API y las funciones definer que comprueban
current_user. - Más sobre lo que pueden saltarse las funciones definer está en NULL en IF NOT de PL/pgSQL: saltó el control en 3683 tareas, y las políticas que llaman a esos ayudantes están en Políticas RLS de Supabase: ejemplos probados con la API.