TL;DR
SECURITY DEFINER関数の中の権限チェックは、if not (has_perm or created_by = me or assignee_id = me) then raiseという形でした。2026年6月の時点で、アプリの3683件のタスクはすべて、作成者も担当者もいない状態でした。false OR NULL OR NULLはNULLで、NOT NULLもNULLのままであり、IFは条件がNULLのときに本体を飛ばします。そのためエラーは一度も発生せず、サインイン済みのユーザーなら誰でもタスクを複製できました。修正は、条件全体をcoalesce(..., false)で包むことです。
これは監査法人の工数管理アプリの、「タスクの複製」の裏にある関数での話です。
チェックはどんな形だったのか?
if not ( has_perm('tasks', 'create')
or v_task.created_by = auth.uid()
or v_task.assignee_id = auth.uid() ) then
raise exception 'forbidden';
end if;
読み方は「タスクを作成する権限がある、自分で作った、または自分が担当者でない限り拒否する」です。3683件のタスクは自動で投入されたものだったため、そのすべてでcreated_byとassignee_idがNULLでした。すべての権限をオフにしたロールでは、条件はNOT (false OR NULL OR NULL)、つまりNULLになり、raiseは一度も実行されませんでした。
なぜRLSでは同じ条件が安全なのか?
row level securityは、trueでないものをすべて拒否として扱うため、NULLは「いいえ」を意味します。PL/pgSQLのIFは、trueでないものをすべて「本体を飛ばす」として扱い、ここでは本体が拒否の処理でした。PostgreSQLのドキュメントは、row securityとIFのそれぞれでこの両面を説明しており、組み合わせると、同じ式が一方では拒否、もう一方では許可になります。
SECURITY DEFINER関数はRLSも素通りするため、関数自身のチェックが唯一のチェックです。NULLの項が1つあれば十分で、ほかの項はすべてfalseでも構いません。
何が修正になったのか?
条件全体を包んでNULLを拒否に変え、複製は元のタスクの編集ではなく、タスクの作成として扱います。
if not coalesce((select has_perm('tasks', 'create')), false) then
raise exception 'forbidden' using errcode = '42501';
end if;
再発したのか?
はい、約3か月後に、さらに3つの関数で起きました。タスクのアーカイブ、種類の変更、そのタスクに工数を記録できるかの確認です。SQLを読んでも見つかりませんでした。見つけたのはロールごとのRLS証明です。権限を1つずつオフにして、操作が拒否されることを確認するからです。
どう洗い出す?
grep 'if not ('で見つかるのは構文であって、バグではありません。うまくいった方法は次のとおりです。
pg_get_functiondefで、すべての関数本体を1回のクエリでダンプします。- 各
if not (...) thenとif a <> b thenから、coalesce(...)、exists(...)、(select ...)を取り除きます。NULLを許容する列に対する、そのままのorの連鎖や比較は候補です。 if not <変数> thenも探し、その変数がどこで設定されるかをたどります。_ok := has_perm(...) or created_by = me;は、同じバグを2行に隠します。if not helper(...)の形で使われている各ヘルパーを、ロールバックするトランザクションの中で3通りに呼びます。存在しないID、NULLの引数、そして期待される行がない実在のユーザーです。どれかでNULLを返すヘルパーには、呼び出し側かヘルパー内部にcoalesceが必要です。
そのアプリでは、洗い出しで14個のヘルパーを確認し、すべてすでに安全でした。バグは複製の関数と、後から見つかった3つに限られていました。マルチテナントでの関連する抜け穴については、2つの組織に属するユーザーでRLSが見逃すものを参照してください。このアプリは監査法人の工数管理アプリで、ほかの見えないミスは監査法人の工数管理にまとめています。Supabase セキュリティチェックは45件のルールの中でSECURITY DEFINER関数を一覧にしますが、その中のIFがチェックを素通りさせる(fails open)かどうかは判断できないため、この洗い出しは手作業のままです。