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
  • この種の穴は動作テストでは見つからない。権限の面から別途レビューする