Supabase で作っていたゲームのコードレビューで、致命的な穴が 2 つ見つかった。どちらも「クライアントから改ざんできてはいけない値」の置き場所の問題だった。
1 つ目は、サーバーが決めるはずの到達記録を profiles テーブルの列に置いていたこと。2 つ目は、security definer の関数が外から誰でも叩けたこと。
両方とも動作は正常で、テストも通っていた。穴は「動くかどうか」では見つからない。
Supabase で作っていたゲームのコードレビューで、致命的な穴が 2 つ見つかった。どちらも「クライアントから改ざんできてはいけない値」の置き場所の問題。両方とも動作は正常でテストも通っていた。
穴 1: profiles の列は本人が書ける
多くの Supabase プロジェクトには「本人は自分の行を更新できる」という update policy がある:
create policy profiles_update on profiles
for update using (id = auth.uid());
この状態で profiles に「到達した最深階」「スコア」みたいなサーバーが決めるべき値の列を足すと、クライアントが PATCH /rest/v1/profiles を直接叩くだけで好きな値に書き換えられる。アプリの UI を経由する必要すらない。
直し方: 書き込み policy の無いテーブル
サーバー権威の値は専用テーブルに分ける。鍵は INSERT / UPDATE / DELETE の policy を一切作らないこと:
create table depth_records (...);
alter table depth_records enable row level security;
create policy depth_records_select on depth_records
for select using (user_id = auth.uid());
-- 書き込み系 policy は作らない = service_role だけが書ける
RLS 有効 + select policy のみ、で「本人は読める・書けるのはサーバーだけ」になる。
穴 2: security definer 関数は初期状態で誰でも実行できる
通貨を増減する関数を security definer で作っていた。PostgreSQL の関数は作った時点で PUBLIC に EXECUTE が付き、Supabase は /rest/v1/rpc/<関数名> で REST 公開する。つまり匿名ユーザーが好きな引数で通貨付与関数を直接叩けた。
直しは 2 行:
revoke execute on function apply_currency from public, anon, authenticated;
alter function apply_currency set search_path = public;
呼び出し元がサーバーだけの関数は、作ったら必ず revoke する。
おまけ: 報酬付与は順番で事故る
報酬の二重取り防止は「付与 → 受領記録 INSERT」の順にする。逆 (記録してから付与) だと、途中で落ちた時に記録だけ残って報酬が消える。
まとめ
- クライアントが update できるテーブルに、サーバーが決める値を置かない
- サーバー権威の値は「select policy だけのテーブル」へ
- security definer 関数は作ったら即 revoke execute
- この種の穴は動作テストでは見つからない。権限の面から別途レビューする
コメント (0)
まだコメントなし。