TL;DR
この警告は、関数に固定されたsearch_pathがないことを意味します。そのため、関数内の修飾されていない名前(たとえばadmins)は、呼び出した側のセッションの設定どおりに探されます。私のローカルのテストでは、管理者ではないユーザーがadminsという名前の一時テーブルを作ると、SECURITY DEFINER関数is_admin()はそのユーザーにtrueを返し、search_path = publicに固定しても防げませんでした。有効だったのは、set search_path = ''にして、すべてのテーブルをpublic.adminsと書く方法です。
SupabaseのSecurity Advisorは、これをlint 0011「Function Search Path Mutable」、レベルWARNとして表示し、詳細は「Function public.x has a role mutable search_path」です。この設定のない関数はすべて、SECURITY INVOKERのものも含めて指摘されるため、補助関数の多いプロジェクトでは一覧が長くなります。あるオープンソースのプロジェクトは、1回のマイグレーションでそのうち99件を修正しました。
「role mutable search_path」とは?
search_pathは、スキーマ名が前に付いていない名前をPostgresが探すスキーマの一覧です。独自の設定がない関数は、呼び出した側の一覧を使います。Supabaseのlintのページは、これを空文字列に固定することを勧めています。そうすると、関数内のすべての参照を完全修飾で書かなければならなくなります。
SECURITY DEFINER関数では、これがとりわけ重要です。関数は所有者の権限、通常はpostgresで実行され、RLSを迂回します。呼び出した側が関数の読むadminsを操作できるなら、postgresとして動くチェックを操作できることになります。
私のテストで悪用できた?
ローカルのPostgres 17.10で、管理者が1人いるpublic.adminsテーブルと、同じ関数select exists (select 1 from admins where user_id = uid)の4つのバージョンを作りました。すべてSECURITY DEFINERです。次に、SQLを実行でき、一時テーブルを作るデフォルトの権限を持つ低権限のロールでログインし、管理者ではないユーザーAを入れたpg_temp.adminsを作りました。
| 関数の設定 | lintの判定 | 一時テーブル作成後のis_admin(A) |
|---|---|---|
| なし | 指摘される | true |
set search_path = public |
通る | true |
set search_path = ''、本文はpublic.adminsを使用 |
通る | false |
set search_path = public, pg_temp |
通る | false |
驚いたのは2行目です。Supabaseのメンテナーは、空でない固定パスにセキュリティ上のリスクはないと2024年に書いており、lintもそれを受け入れます。しかしPostgresは、パスにpg_tempが明示されていない限り、まず一時スキーマを探します。SECURITY DEFINER関数の書き方に関するドキュメントも警告しているとおりです。そのためsearch_path = publicでも、一時テーブルが修飾されていない名前を覆い隠せてしまいます。
Supabaseで実際にこれができるのは誰?
アプリ経由の訪問者ではありません。Data APIは一時テーブルを作ることも、セッションのsearch_pathを変えることもできないので、公開キーだけでは届きません。必要なのはデータベースへの直接接続です。独自のログインを持つ別のサービス、BIやレポートのツール、業務委託先の読み取り専用ロール、漏れた接続文字列などです。小さなアプリの多くではリスクは低いですが、あなた以外の人やツールがデータベースのログインを持つプロジェクトでは、definer関数を最初に直してください。
どう直す?
関数を、空のパスと、本文中のスキーマ名付きで作り直します。
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;
revokeはパスと同じくらい重要です。publicにあるdefiner関数はData API経由で呼び出せるため、これがないと、ゲストが/rpc/is_adminに特定のユーザーが管理者かどうかを問い合わせられます。必要なロールにだけ付け直すか、このような補助関数はAPIに公開されていないスキーマに置いてください。
alter function public.is_admin(uuid) set search_path = ''だけを実行してはいけません。Advisorは静かになりますが、関数が壊れます。私の実行では、次の呼び出しが42P01: relation "admins" does not existで失敗しました。拡張機能の演算子にもスキーマが必要で、pgvectorならoperator(extensions.<=>)のように書きます。
lintがpublicで指摘するものを、definer関数を先頭にして一覧するには次のとおりです。
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;
修正にコストはかかる?
場合によってはかかります。set句が付くと、Postgresは単純なSQL関数を呼び出し元のクエリにインライン展開できなくなります。GitHubでも報告されています。フィルター対象の列にインデックスのある200,000行で、両方の形を計測しました(7回の中央値)。
| SQL関数を所有者で絞り込む | setなし |
set search_path = '' |
|---|---|---|
外側でフィルター:visible_items() where owner = ... |
0.053 ms、インデックス使用 | 36.7 ms、全件スキャン |
内側でフィルター:items_of(owner) |
0.056 ms | 0.082 ms |
集合を返すSQL関数を外側のフィルター付きで呼び出しているなら、パスを固定する前に、フィルターをパラメーターとして関数の中に移してください。
プロジェクトを確認する
- Advisorでは、一覧にある
SECURITY DEFINER関数を先に直してください。invoker関数はどのみち呼び出し元の権限で動きます。 - 無料のSupabaseセキュリティチェックは、Advisorの指摘を独自のルールと並べて表示します。APIから呼び出せるdefiner関数や、
current_userを確認するdefiner関数も含まれます。 - definer関数がすり抜けられるものについてはPL/pgSQLのIF NOTでNULLがチェックを飛ばした:3683タスクに、そのような補助関数を呼ぶポリシーについてはSupabase RLSポリシー例:APIで検証済みにあります。