Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

SECURITY DEFINER関数はPUBLIC EXECUTE既定でanonから直接呼べる

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

PostgreSQLはCREATE FUNCTIONの既定でPUBLIC EXECUTE権限を与えるため、SECURITY DEFINERの書込関数をrevokeせずに作成すると、SupabaseのPostgREST層を経由してanonロールから直接呼び出せる状態になる。

結論

PostgreSQLは CREATE FUNCTION を実行した時点で、既定として実行権限(EXECUTE)を PUBLIC ロールに付与します。 SECURITY DEFINER とは、呼び出したロールではなく関数を所有するロールの権限で実行させる修飾子のことです。この修飾子で書き込みを行う関数を作った直後、明示的に REVOKE しないままにしておくと、Supabase の PostgREST 層を経由して anon(未認証)や authenticated ロールから、その関数を直接呼び出せる状態になります。

  • GRANT を一切書いていなくても、PUBLIC への EXECUTE は最初から付いている
  • テーブル側に RLS ポリシーを設定していても、SECURITY DEFINER 関数はオーナー権限で動くため無関係
  • 塞ぐには REVOKE ALL ... FROM PUBLIC を明示的に書き、必要なロールにだけ GRANT し直す

症状

sync_outbox というテーブルに、非同期送信用のレコードを積む関数を作った。関数の定義には search_path を固定する指定が入っている。search_path とは、関数内で無条件のオブジェクト名をどのスキーマから解決するかを決めるスキーマ探索パスのことで、呼び出し元がこれを書き換えて public 以外に置いた偽の同名オブジェクトを先に解決させる攻撃を防ぐため、public に固定するのが定石になっている。

create table if not exists public.sync_outbox (
  id            uuid primary key default gen_random_uuid(),
  kind          text not null,
  ref_id        uuid,
  payload       jsonb not null default '{}'::jsonb,
  status        text not null default 'pending'
                check (status in ('pending','sent','failed','dead')),
  attempts      int  not null default 0,
  -- (略)
);

alter table public.sync_outbox enable row level security;
create policy sync_outbox_staff_read on public.sync_outbox
  for select using (public.is_staff());

create or replace function public.enqueue_sync_outbox(p_kind text, p_ref_id uuid, p_payload jsonb)
returns uuid language sql security definer set search_path = public as $$
  insert into public.sync_outbox (kind, ref_id, payload)
  values (p_kind, p_ref_id, coalesce(p_payload, '{}'::jsonb))
  returning id;
$$;

テーブルの RLS は「staff ロールの SELECT のみ許可」としか書いていない。anon への INSERT を許すポリシーはどこにもない。関数もアプリのバックエンド(cron ワーカー)からしか呼ばれない前提で書いていた。search_path の固定は対策済みだったが、それは別種の攻撃への備えであって、今回の問題はここでは防げない。

だがこの関数は SECURITY DEFINER で、CREATE FUNCTION した瞬間に PUBLICEXECUTE 権限が既定で付いている。 Supabase の PostgREST は、public スキーマの関数を自動的に /rest/v1/rpc/<関数名> として公開する。呼び出し側が送る JWT に応じて anon または authenticated ロールで接続するが、どちらのロールも PUBLIC の権限を継承しているため、明示的に REVOKE していない関数は誰でも直接叩ける。

レビューで指摘されたのはここだった。テーブルの RLS は正しく staff の読み取りだけに絞っていたのに、関数の実行権限は一度も絞っていなかった。

原因

3つの事実が重なっている。

SECURITY DEFINER は関数オーナーの権限で走る

呼び出したロールの権限ではなく、関数を所有しているロールの権限で実行される。ここまでは仕様どおり。

CREATE FUNCTION は既定で PUBLICEXECUTE を付与する

GRANT を一度も書いていなくても、関数を作った時点で誰でも呼べる状態になっている。テーブルの GRANT は既定で閉じている(明示的に許可しないと誰も触れない)のと対照的で、関数は既定で開いている。

③ PostgREST は public スキーマの関数を自動的に RPC として公開する

anon / authenticated は Supabase が用意する接続用ロールで、PUBLIC の権限をそのまま継承する。テーブルへの GRANT を一つも書いていなくても、SECURITY DEFINER 関数さえREVOKE し忘れていれば、RLS を丸ごと迂回する書き込み経路が空いたままになる。

テーブルの GRANT   → 既定で閉じている → 明示的に許可しないと誰も触れない
関数の EXECUTE     → 既定で開いている → 明示的に revoke しないと誰でも呼べる

この非対称性が見落としの原因になる。テーブル側だけを見て「RLS で守られている」と判断すると、関数側の穴に気づけない。

直す

既定の PUBLIC 権限を剥がし、必要なロールにだけ許可し直す。

-- SECURITY DEFINER の書込関数を anon/authenticated(PostgREST 経由)から直接呼べないよう閉じる。
-- 既定の PUBLIC EXECUTE を剥がし、バックエンド(service_role)のみに許可。
revoke all on function public.enqueue_sync_outbox(text, uuid, jsonb) from public;
grant execute on function public.enqueue_sync_outbox(text, uuid, jsonb) to service_role;

REVOKE ALL ... FROM PUBLICanon / authenticated を含むすべての継承元を切り離し、実際に呼び出す必要がある service_role にだけ GRANT EXECUTE を戻す。関数の定義そのものは変更していない。開いていた入口を閉じただけで、意図した呼び出し経路(バックエンド側)には影響しない。

確認する

pg_procpg_roles を結合すると、関数のオーナーと実行権限を引ける。

select p.proname, r.rolname as owner, p.proacl
from pg_proc p
join pg_roles r on r.oid = p.proowner
where p.proname = 'enqueue_sync_outbox';

proaclPUBLIC への EXECUTE=X/<owner> のような表記)が残っていないかを見る。SECURITY DEFINER で作った書込系の関数は、この確認を新規作成のたびに行わないと、revoke を書き忘れた関数が増えるだけ同じ経路が空く。

再発しない形にする

この修正は、関数を作り直すのではなく、既存のマイグレーションファイルに revoke / grant を追記する形で入れた。SECURITY DEFINER の関数を作るたびに、REVOKE ALL ... FROM PUBLIC を対で書くことをルールにしないと、次に追加する書込関数でも同じ抜け道が空く。

  • SECURITY DEFINER を使う書込関数は、作成 SQL のすぐ下に REVOKE / GRANT を並べて書き、片方だけ足すことを構造的にできなくする
  • テーブルの RLS レビューだけでなく、関数の EXECUTE 権限も同じレビュー項目に含める(テーブルは既定で閉じているが、関数は既定で開いているという非対称性を前提にする)
  • 匿名で書き込めるかどうかは、ポリシーの文面ではなく pg_proc.proacl を実際に引いて確認する

RLS のポリシーをどれだけ丁寧に書いても、関数の実行権限という別の扉が開いていれば意味がない。 同じ「SECURITY DEFINER の見落とし」という形の話として、関数オーナーとマイグレーション実行ロールの話 も書いている。あちらは意図したバイパスが効かなくなる話で、こちらは意図しないバイパスが効いてしまう話 ── どちらも SECURITY DEFINER は「誰が呼んだか」ではなく「誰が作ったか」で挙動が決まる、という同じ性質が起点になっている。

よくある質問

Q1なぜSECURITY DEFINER関数がanonからも呼べてしまうのですか?

PostgreSQLはCREATE FUNCTIONを実行した時点で、既定として実行権限(EXECUTE)をPUBLICロールに付与します。SupabaseのPostgRESTはリクエストのJWTに応じてanonまたはauthenticatedロールで接続しますが、どちらのロールもPUBLICの権限を継承するため、明示的にrevokeしていない関数はanonからでも/rest/v1/rpc/経由で直接呼び出せます。

Q2GRANTを書いていないのに、なぜ実行できてしまうのですか?

GRANTは権限を追加するための文であり、権限を制限するための文ではありません。PostgreSQLの関数はCREATE時点でPUBLICへのEXECUTEが既定で付くため、制限したい場合は逆にREVOKEを明示的に書く必要があります。GRANTを書かなかったことが原因ではなく、REVOKEを書かなかったことが原因です。

Q3テーブルにRLSを設定していれば防げませんか?

防げません。SECURITY DEFINER関数は呼び出したロールではなく、関数のオーナーの権限で実行されます。sync_outboxテーブルのRLSはstaffロールのSELECTしか許可していませんでしたが、enqueue_sync_outbox関数はオーナー権限で動くINSERTなので、テーブル側のポリシーとは無関係に書き込めてしまいます。

Q4直し方は何をすればいいですか?

REVOKE ALL ON FUNCTION <関数名>(<引数型>) FROM PUBLIC; で既定の実行権限を剥がし、続けてGRANT EXECUTE ON FUNCTION <関数名>(<引数型>) TO service_role; のように、実際に呼び出す必要があるロールにだけ許可し直します。

Q5他のSECURITY DEFINER関数も同じ危険がありますか?

個別に確認が要ります。pg_procとpg_rolesを結合してproaclを見れば、PUBLICへのEXECUTEが残っている関数を洗い出せます。SECURITY DEFINERで定義した書込系の関数は、作成のたびにREVOKEを対にする運用にしないと、同じ抜け道が関数の数だけ増えます。

確認した環境

  • PostgreSQL (Supabase) / @supabase/supabase-js ^2.106.2
  • 2026-06-10、Phase4 レビュー指摘(High)への対応として migration 0025 に追記

この記事の根拠

  • SQLファイル 8〜50行目コミット 3018abf

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。