Kurz gesagt
Die Warnung bedeutet, dass eine Funktion keinen festen search_path hat; jeder unqualifizierte Name darin, etwa admins, wird so aufgelöst, wie es die Sitzung des Aufrufers vorgibt. In meinem lokalen Test lieferte eine SECURITY DEFINER-Funktion is_admin() für einen Nicht-Admin true, nachdem dieser Nutzer eine temporäre Tabelle namens admins angelegt hatte, und search_path = public zu setzen verhinderte das nicht. Was funktionierte: set search_path = '' und jede Tabelle als public.admins geschrieben.
Der Security Advisor von Supabase führt das als Lint 0011, “Function Search Path Mutable”, Stufe WARN, mit dem Detail “Function public.x has a role mutable search_path”. Er meldet jede Funktion ohne die Einstellung, auch SECURITY INVOKER-Funktionen, sodass die Liste in einem Projekt mit vielen Helfern lang wird: Ein Open-Source-Projekt behob 99 davon in einer einzigen Migration.
Was heißt “role mutable search_path”?
search_path ist die Liste der Schemas, in denen Postgres nach einem Namen ohne vorangestelltes Schema sucht. Eine Funktion ohne eigene Einstellung nutzt die Liste des Aufrufers. Die Lint-Seite von Supabase empfiehlt, ihn auf einen leeren String zu setzen, was jeden Verweis in der Funktion zwingt, vollständig qualifiziert zu sein.
Bei einer SECURITY DEFINER-Funktion ist das am wichtigsten: Sie läuft mit den Rechten ihres Eigentümers, meist postgres, und der umgeht RLS. Wenn der Aufrufer steuern kann, welches admins sie liest, steuert er eine Prüfung, die als postgres läuft.
Lässt sich das in meinem Test ausnutzen?
Auf einem lokalen Postgres 17.10 legte ich eine Tabelle public.admins mit einem Admin an und vier Versionen derselben Funktion, select exists (select 1 from admins where user_id = uid), alle SECURITY DEFINER. Dann meldete ich mich als Rolle mit niedrigen Rechten an, die SQL ausführen darf und standardmäßig temporäre Tabellen anlegen kann, und legte pg_temp.admins mit Nutzer A an, der kein Admin ist:
| Einstellung der Funktion | Lint-Bedingung | is_admin(A) nach der temporären Tabelle |
|---|---|---|
| keine | gemeldet | true |
set search_path = public |
bestanden | true |
set search_path = '', Body nutzt public.admins |
bestanden | false |
set search_path = public, pg_temp |
bestanden | false |
Die zweite Zeile überraschte mich. Ein Supabase-Maintainer schrieb 2024, ein fester nicht leerer Pfad berge kein Sicherheitsrisiko, und der Lint akzeptiert ihn. Aber Postgres durchsucht das temporäre Schema zuerst, sofern pg_temp nicht im Pfad genannt ist, wie es die Dokumentation zum Schreiben von SECURITY-DEFINER-Funktionen warnt. Mit search_path = public kann also weiterhin eine temporäre Tabelle einen unqualifizierten Namen überdecken.
Wer kann das bei Supabase tatsächlich tun?
Kein Besucher über Ihre App. Die Data API kann weder temporäre Tabellen anlegen noch den search_path der Sitzung ändern, der publishable key allein führt also nicht dorthin. Nötig ist eine direkte Datenbankverbindung: ein zweiter Dienst mit eigenem Login, ein BI- oder Reporting-Tool, die schreibgeschützte Rolle eines Auftragnehmers, ein geleakter Connection-String. Bei den meisten kleinen Apps ist das Risiko gering; in jedem Projekt, in dem andere Personen oder Tools als Sie Datenbank-Logins haben, beheben Sie zuerst die Definer-Funktionen.
Wie beheben Sie das?
Legen Sie die Funktion mit leerem Pfad und Schemanamen im Body neu an:
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;
Das revoke ist so wichtig wie der Pfad: Eine Definer-Funktion in public ist über die Data API aufrufbar; ohne es kann jeder Gast bei /rpc/is_admin fragen, ob ein bestimmter Nutzer Admin ist. Vergeben Sie das Recht nur an die Rollen zurück, die es brauchen, oder legen Sie solche Helfer in ein Schema, das die API nicht exponiert.
Führen Sie nicht nur alter function public.is_admin(uuid) set search_path = '' aus. Das bringt den Advisor zum Schweigen und zerstört die Funktion: Bei meinem Lauf scheiterte der nächste Aufruf mit 42P01: relation "admins" does not exist. Auch Operatoren aus Erweiterungen brauchen ihr Schema, etwa operator(extensions.<=>) für pgvector.
So listen Sie auf, was der Lint in public meldet, Definer-Funktionen zuerst:
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;
Kostet die Lösung etwas?
Manchmal. Jede set-Klausel hindert Postgres daran, eine einfache SQL-Funktion in die aufrufende Abfrage einzubetten, wie auf GitHub berichtet. Ich habe beide Formen auf 200.000 Zeilen mit einem Index auf der gefilterten Spalte gemessen (Median aus 7 Läufen):
| SQL-Funktion, danach nach Besitzer gefiltert | Ohne set |
set search_path = '' |
|---|---|---|
Filter außerhalb: visible_items() where owner = ... |
0,053 ms, Index genutzt | 36,7 ms, Full Scan |
Filter innerhalb: items_of(owner) |
0,056 ms | 0,082 ms |
Wird eine mengenliefernde SQL-Funktion mit einem Filter außerhalb abgefragt, verschieben Sie den Filter als Parameter hinein, bevor Sie den Pfad festlegen.
Prüfen Sie Ihr Projekt
- Beheben Sie im Advisor zuerst die
SECURITY DEFINER-Funktionen auf der Liste; Invoker-Funktionen laufen ohnehin mit den Rechten des Aufrufers. - Der kostenlose Supabase-Sicherheitscheck zeigt Advisor-Funde neben seinen eigenen Regeln, darunter über die API aufrufbare Definer-Funktionen und Definer-Funktionen, die
current_userprüfen. - Mehr dazu, was Definer-Funktionen überspringen können, steht in NULL im PL/pgSQL-IF NOT: Prüfung bei 3683 Tasks übersprungen, und Policies, die solche Helfer aufrufen, in Supabase RLS Policy Beispiele, je per API getestet.