実際に何がまずかったのか
SupabaseはPostgresのテーブルを自動生成のREST APIで公開します。フロントエンドはanon key(publishable keyとも呼ばれます)を使ってこのAPIと通信します。このキーはもともと公開される前提のものです。JavaScriptバンドルに含まれて配信され、誰でもDevToolsで読めます。
この設計自体に問題はありません。ただし、すべてのテーブルでRow Level Security(RLS)が有効になっていて、適切なポリシーが設定されていればの話です。RLSは、あるリクエストがどの行を見てよいかを決める仕組みです。TheCyberSecGuruが指摘するように、Supabase自身のドキュメントにはこうあります。「RLSがなければ、anon keyを持つ誰もがすべてのデータにアクセスできます。」
つまり、失敗の連鎖は次のようになります:
- AIツール(または疲れた人間)がcreate table profiles (...)を生成する。
- 誰もenable row level securityを追加しない。
- テストではアプリが完璧に動く。バグの正体は、権限が緩すぎることだからだ。
- バンドルからanon keyをコピーした人なら誰でも、そのテーブルにselect *を実行できる。
RLSの仕組みについて背景を知りたい場合は、Supabase Row Level Securityのガイドで基本を解説しています。以下では、テストと修正に焦点を当てます。
アプリを確認する方法(5分)
1. 研究者と同じように外側から見る。公開中のサイトを開いてDevToolsを開き、読み込まれたJavaScriptからsupabase.coを検索します。プロジェクトのURLと、その横にanon keyが見つかるはずです。次にターミナルから、非公開だと考えているテーブルの行を1件リクエストしてみます:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=1" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"レスポンスからわかること:
- 空の配列[]または権限エラー:問題ありません。匿名ユーザーはそのテーブルを読めません。
- 実際の行が返ってくる:あの7,403サイトと同じ問題を抱えています。
ユーザーデータを保持するすべてのテーブルで繰り返します。profiles、users、messages、orders、bookings、leads、waitlistなどです。waitlistやお問い合わせフォームのテーブルは忘れがちですが、ほぼ確実にメールアドレスが入っています。
バンドルを見ているついでに、見つけたキーがservice_role keyではなくanon keyであることも確認してください。service roleはRLSを完全にバイパスします。フロントエンドに含まれているなら、漏えいしたものとして扱い、今すぐローテーションしてください。バンドル内のものは、すべての訪問者とページ上のすべてのサードパーティスクリプトに読まれます。Polymarketのユーザーが思い知ったとおりです。しかもボットはすでに漏えいしたキーを探し回っています。ローテーションの手順はダウンタイムなしでアプリのシークレットをローテーションする方法の記事で、ほかにバンドルに紛れ込みがちなものについてはクライアントサイドJavaScriptで露出したAPIキーで解説しています。
2. 内側から確認する。SupabaseのSQLエディタで、publicスキーマのすべてのテーブルと、RLSが有効かどうかを一覧表示します:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;rowsecurity = falseの行は、anon keyで読み取り可能(場合によっては書き込みも可能)です。SupabaseダッシュボードのSecurity Advisorもこれらを警告します。警告は無視せず、きちんと読んでください。
3. 「RLSは有効なのに結局開いている」状態を探す。RLSを有効にすればゴールというわけではありません。ポリシーを一覧表示します:
select tablename, policyname, roles, cmd, qual
from pg_policies
where schemaname = 'public';anonまたはpublicに適用されるselectポリシーでqual = trueになっているものに注意してください。これは「誰でもすべてを読める」というポリシーで、RLSがないのと同じです。AIツールはエラーを消すために、まさにこれを生成することがあります。
修正方法
すべてのテーブルでRLSを有効にします:
alter table public.profiles enable row level security;
alter table public.messages enable row level security;
-- repeat for every table in publicRLSが有効でポリシーがない場合、テーブルはロックされ、anon keyでは何も返ってきません。これは安全なデフォルトです。そのうえで、本当に必要なアクセスだけを追加します。
範囲の狭いポリシーを書きます。典型的な「ユーザーは自分の行だけを閲覧・編集できる」設定は次のとおりです:
create policy "Read own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Update own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);注意すべき点:
- データが本当に公開情報(ブログ記事、商品一覧など)でない限り、対象はpublicではなくauthenticatedに絞る。
- 読み取りと書き込みを分ける。公開で読み取っても安全なテーブルでも、insertは厳しく制限する必要がある場合がある。
- 公開テーブルでも列は漏れる。表示名のためにprofilesテーブルを公開するなら、同じテーブルにあるemailやphoneの列も公開されてしまう。非公開のフィールドは別テーブルに移すか、安全な列だけを含むビューを公開する。
- ビューはRLSをバイパスできる。Postgresでは、ビューはデフォルトで所有者の権限で実行される。security_invoker = trueでビューを作成し、呼び出し元のRLSが適用されるようにする。
- Storageは別物。公開バケットは、URLを知っている誰にでもファイルを配信する。アップロードされた身分証、請求書、非公開ユーザーのアバターは、ストレージポリシー付きの非公開バケットに置く。
後片付けではなく、デフォルトにする。研究者のサンプルは10か月分の新しいアプリにまたがっており、新しいテーブルが作られるたびにこの問題が繰り返されていることを意味します。忘れにくい仕組みにしましょう:
- AIツールのプロジェクト指示にルールを追加する:publicでのcreate tableには、必ずenable row level securityと明示的なポリシーを続けること。
- マージする前に、生成されたマイグレーションに、対応するenable row level securityのないcreate tableがないかレビューする。
- ローンチ時だけでなく、スキーマを変更するたびに外側からのチェックを再実行する。
SupabaseではなくFirebaseを使っている場合も、同じ種類のミスがオープンなセキュリティルールという形で存在します。Firebase Security Rules:データベースを開けっ放しにしないを参照してください。
露出したデータが見つかったら
ポリシーを修正すれば今後の読み取りは止まりますが、過去の読み取りは取り消せません。何に、どれくらいの期間アクセス可能だったのかを正直に見極めてください。SupabaseのAPIログで見慣れないIPからの異常な一括読み取りがないか確認し、読み取り可能なテーブルに保存されていたシークレットはすべてローテーションし、メールアドレス、身分証、決済情報などの個人データが露出していた場合は通知義務を確認してください。EUでは、GDPRの侵害通知ルールが適用される可能性が高いでしょう。
クイックチェックリスト
完了とする前に、次の点を確認してください:
- publicのすべてのテーブルでrowsecurity = trueになっている。
- 非公開データを持つテーブルに、using (true)を使うselectポリシーがない。
- ポリシーの対象がauthenticatedに絞られ、auth.uid()に結び付いている。
- 非公開の列が、公開で読み取れるテーブルに置かれていない。
- ビューがsecurity_invoker = trueを使っている。
- フロントエンドにあるのはanon keyだけで、service_roleは決して含まれていない。
- 非公開ファイルは非公開のストレージバケットにある。
- スキーマを変更するたびに、外側から再テストしている。
外側からアプリを確認しよう
AIで作ったアプリのセキュリティチェックリストでは、ローンチ前に確認しておくべきほかの項目を取り上げています。この調査で居心地が悪いのは、手法があまりにも平凡だったことです。公開キーで読み取り専用のリクエストを送れば、データが返ってくる。それはまさに攻撃者が見ている景色であり、あなたが定期的に確認すべき景色でもあります。
AppSafeは公開中のアプリを外側からスキャンします。認証済みのフルスキャンには、オープンなSupabaseやFirebaseのデータベースを調べる読み取り専用のチェックと、JavaScriptバンドル内に露出したAPIキーの検出が含まれます。スケジュールスキャンを使えば、デプロイ後に何かが変わったときにアラートを受け取れます。レポートがどんなものか気になりますか?サンプルレポートをご覧ください。
今すぐ無料でアプリをスキャンして、ほかの誰かより先に、見知らぬ人に何が見えているのかを確かめましょう。
