RLSのINSERT WITH CHECKが会社IDしか見ず、承認ステータス列を自己申告できた
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
SupabaseのRLS INSERTポリシーは行の所有者(company_id)を検証していても、他の列の値までは自動では守らない。新しく追加した列をWITH CHECKに含め忘れると、その列はクライアントが自由な値でINSERTできる列として残る。
結論
広告主向けのcreativesテーブルには、INSERTを許可するRLSポリシーがあった。このポリシーはcompany_idが自社と一致すること、statusが'審査中'であることは検証していたが、その後のマイグレーションで追加した承認ステータス列(学校側の確認状態・運営の承認記録)は一切検証していなかった。
- 広告主のセッションで使うSupabaseクライアントは、RLSで許可されている範囲の操作を直接実行できる
- アプリのサーバーアクションは承認ステータス列を送っていなかったが、それはアプリの都合であってRLSの制約ではない
- PostgRESTへ直接INSERTを送れば、承認ステータス列に任意の値(たとえば「学校側は確認済み」を意味する値)を自己申告でき、実際には誰も確認していない状態で「確認済み」の行を作れた
WITH CHECKに、承認ステータス列一式がNULLであることを追加する条件を足して塞いだ。
症状
このプロダクトの入稿フローは、規格チェックに合格した広告素材を、学校側の担当者と運営の両方が確認してから掲載する二者承認ゲートになっている。この仕組みを追加したマイグレーションで、creativesテーブルに学校側の確認状態・確認トークンのハッシュ・確認期限・運営側の承認記録という列が増えた。
このマイグレーションのコメントには、確認トークンをハッシュ化して保存する理由が書かれている。
-- 学校ゲートのトークンは **ハッシュのみ** 保存(広告主は自社 creatives 行を RLS で読めるため、
-- 生トークンを行に置くと広告主が先生になりすまして承認できてしまう)。
広告主は自社のcreatives行をRLSで読める。だから生のトークンをそのまま列に置くと、広告主がそのトークンを読み取り、学校側の確認リンクへ自分でアクセスして「確認済み」にしてしまう——という、読み取り経由のなりすまし経路は最初から警戒されていた。
だが、書き込み経由の経路は別に残っていた。広告主向けのINSERTを許可する既存のRLSポリシーは、この列追加の時点で更新されていなかった。会社IDの一致とステータスが'審査中'であることしか検証していないポリシーのもとでは、新しく増えた承認ステータス列はINSERT時に無条件で通る列として残る。広告主が素材を投稿する際、学校側の確認状態を最初から「確認済み」を意味する値にして送れば、その通りに書き込めた。
アプリのコードは安全、だが境界はアプリではない
広告主向けの投稿フォームが呼ぶサーバーアクションのINSERT呼び出しを見ると、実際に送っている列は次の通りだった。
const { data: inserted, error: insErr } = await supabase
.from("creatives")
.insert({
company_id: user.companyId,
placement_id: validPlacementId,
submitted_by: user.id,
title,
file_path: path,
file_name: file.name,
mime_type: mime,
size_bytes: file.size,
media_type: media,
duration_sec: media === "image" ? spec.spec_duration_sec : null,
spec: toCreativeSpec(evalArgs, result),
status: "審査中",
valid_from: period.validFrom,
valid_to: period.validTo,
})
.select("id")
.single();
承認ステータス列は一度も出てこない。この呼び出しだけを見れば「アプリは安全な値しか送っていない」と言える。
ただし、このsupabaseはservice_roleのクライアントではなく、ログインユーザーのセッションに紐づくクライアントである。RLSで許可されている操作は、このサーバーアクションを経由しなくても、ログイン済みのセッションからPostgRESTへ直接同じテーブルにINSERTを送れば同じように通る。つまり実際の境界線はサーバーアクションのコードではなく、RLSポリシーのWITH CHECK条件そのものだった。アプリのコードが安全であることと、RLSが安全であることは別の話で、後者が緩ければ前者だけでは守れない。
直す
WITH CHECKに、承認ステータス列一式がNULLであることを追加する条件を足した。
drop policy if exists creatives_customer_insert on public.creatives;
create policy creatives_customer_insert on public.creatives
for insert
with check (
company_id = public.current_company_id()
and status = '審査中'
-- ゲート状態は広告主が設定できない(起票・決定は service_role 経由のみ)
and school_gate_status is null
and school_gate_token_hash is null
and school_gate_due is null
and school_gate_decided_at is null
and school_gate_note is null
and ceo_gate_at is null
and ceo_gate_by is null
-- 審査結果も自己申告不可(既存方針の明文化)
and reviewed_by is null
and reviewed_at is null
and review_note is null
);
これで、承認ステータス列に何か値が入った状態のINSERTはRLSの時点で拒否される。これらの列に値を入れられるのは、起票処理(学校側への確認リンク送付)と承認確定処理を担うservice_role経由の処理だけになった。広告主が投稿時に行えるのは、素の申請を作ることだけに戻った。
再発しにくい形
company_idが自社と一致するかという検証は、この列そのものを他社になりすまして書き換えられないようにするための条件であって、テーブルに後から追加した別の列まで面倒を見てくれるわけではない。承認ゲートの列は、承認ゲートという機能と一緒に後から増えた列であり、それを守る条件は、その機能を作ったときに自分で足す以外に増える手段が無い。
RLSでINSERTを許可しているテーブルに列を追加するときは、「この新しい列は、既存のWITH CHECKの条件式で本当に検証されているか」を、列を足すたびに見直す必要がある。所有者列の検証が通っていることと、個々の列の値が信頼できることは別の話だ。
よくある質問
Q1なぜこの抜け道に気づかなかったのですか?
承認ステータス列を追加したマイグレーションのコメントには、確認トークンをハッシュ化して保存する理由(広告主が生トークンを読んで先生になりすます経路の防止)は書かれていましたが、ステータス列自体を広告主がINSERT時に指定できる点への言及はありませんでした。一つのなりすまし経路を塞いだことで、列そのものを自己申告できるという別の経路が見落とされていました。
Q2アプリのコードは安全な値しか送っていなかったのでは?
その通りで、広告主向けの投稿フォームが呼ぶサーバーアクションは、承認ステータス列を一度も送信していませんでした。ただしこの呼び出しはservice_roleではなく、ログインユーザーのセッションに紐づくクライアントを使っています。RLSで許可されている操作は、アプリの外からPostgRESTへ直接INSERTを送っても同じように通るため、アプリのコードが安全でもRLSの条件自体が緩ければ抜け道は残ります。
Q3修正の考え方を一言で言うと?
列を追加するたびに、WITH CHECKの条件式を『この列も検証対象に含めたか』という視点で見直すことです。会社IDのような所有者列の検証だけでは、その後に追加した列は自動的には保護されません。
確認した環境
- @supabase/supabase-js ^2.106.2 / PostgreSQL RLS
- 2026-06-12、Reviewer指摘(B-2)への対応として修正
この記事の根拠
- SQLファイル 1〜32行目コミット fa31756
- SQLファイル 1〜27行目コミット eec719b
- TypeScriptファイル 125〜143行目コミット eec719b
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。