本文へスキップ

PL/pgSQLのIF NOTでNULLがチェックを飛ばした:3683タスク

Postgres

Markdownで表示Claudeで開くChatGPTで開くPerplexityで開く

質問もコピーされます。チャットが空で開いた場合は貼り付けてください。

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のそれぞれでこの両面を説明しており、組み合わせると、同じ式が一方では拒否、もう一方では許可になります。

同じNULLが、正反対の答えになる。has_permはfalse、created_byとassignee_idはNULL。RLSポリシーの中:false OR NULL → NULL → 行は拒否、IF NOT (...) THEN RAISE の中:false OR NULL → NOT NULL は NULL → RAISEは実行されない → 複製が許可される。
PostgreSQL 18のドキュメントが説明する三値論理。5.9節(row security)と41.6.4節(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 ('で見つかるのは構文であって、バグではありません。うまくいった方法は次のとおりです。

  1. pg_get_functiondefで、すべての関数本体を1回のクエリでダンプします。
  2. 各if not (...) thenとif a <> b thenから、coalesce(...)、exists(...)、(select ...)を取り除きます。NULLを許容する列に対する、そのままのorの連鎖や比較は候補です。
  3. if not <変数> thenも探し、その変数がどこで設定されるかをたどります。_ok := has_perm(...) or created_by = me;は、同じバグを2行に隠します。
  4. if not helper(...)の形で使われている各ヘルパーを、ロールバックするトランザクションの中で3通りに呼びます。存在しないID、NULLの引数、そして期待される行がない実在のユーザーです。どれかでNULLを返すヘルパーには、呼び出し側かヘルパー内部にcoalesceが必要です。

そのアプリでは、洗い出しで14個のヘルパーを確認し、すべてすでに安全でした。バグは複製の関数と、後から見つかった3つに限られていました。マルチテナントでの関連する抜け穴については、2つの組織に属するユーザーでRLSが見逃すものを参照してください。このアプリは監査法人の工数管理アプリで、ほかの見えないミスは監査法人の工数管理にまとめています。Supabase セキュリティチェックは45件のルールの中でSECURITY DEFINER関数を一覧にしますが、その中のIFがチェックを素通りさせる(fails open)かどうかは判断できないため、この洗い出しは手作業のままです。

© 2026 Denys Zaichenko. 元の記事: https://madsalt.tech/ja/notes/plpgsql-if-not-null-fails-open/