---
title: "PL/pgSQLのIF NOTでNULLがチェックを飛ばした：3683タスク"
description: "SECURITY DEFINER関数ではfalse OR NULLがNULLになり、IF NOTがRAISEを飛ばします。RLSでは安全な理由と修正を示します。"
url: "https://madsalt.tech/ja/notes/plpgsql-if-not-null-fails-open/"
author: "Denys Zaichenko"
language: "ja"
published: 2026-10-18
---

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

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)`で包むことです。

これは[監査法人の工数管理アプリ](https://madsalt.tech/ja/work/audit-firm-time-tracking/)の、「タスクの複製」の裏にある関数での話です。

## チェックはどんな形だったのか？

```sql
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](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)と[IF](https://www.postgresql.org/docs/current/plpgsql-control-structures.html)のそれぞれでこの両面を説明しており、組み合わせると、同じ式が一方では拒否、もう一方では許可になります。

![同じ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は実行されない → 複製が許可される。](https://madsalt.tech/charts/ja/plpgsql-if-not-null-fails-open.svg)

*PostgreSQL 18のドキュメントが説明する三値論理。5.9節（row security）と41.6.4節（IF）。*

`SECURITY DEFINER`関数はRLSも素通りするため、関数自身のチェックが唯一のチェックです。`NULL`の項が1つあれば十分で、ほかの項はすべて`false`でも構いません。

## 何が修正になったのか？

条件全体を包んで`NULL`を拒否に変え、複製は元のタスクの編集ではなく、タスクの作成として扱います。

```sql
if not coalesce((select has_perm('tasks', 'create')), false) then
  raise exception 'forbidden' using errcode = '42501';
end if;
```

## 再発したのか？

はい、約3か月後に、さらに3つの関数で起きました。タスクのアーカイブ、種類の変更、そのタスクに工数を記録できるかの確認です。SQLを読んでも見つかりませんでした。見つけたのは[ロールごとのRLS証明](https://madsalt.tech/ja/blog/how-to-test-supabase-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が見逃すもの](https://madsalt.tech/ja/notes/supabase-rls-user-in-two-organizations/)を参照してください。このアプリは監査法人の工数管理アプリで、ほかの見えないミスは[監査法人の工数管理](https://madsalt.tech/ja/blog/time-tracking-for-audit-firms/)にまとめています。[Supabase セキュリティチェック](https://madsalt.tech/ja/tools/supabase-security-check/)は45件のルールの中でSECURITY DEFINER関数を一覧にしますが、その中のIFがチェックを素通りさせる（fails open）かどうかは判断できないため、この洗い出しは手作業のままです。
