本文へスキップ

Lovableアプリのセキュリティ:12の確認と追加の8つ

更新日

Markdownで表示Claudeで開くChatGPTで開くPerplexityで開く

質問もコピーされます。チャットが空で開いた場合は貼り付けてください。

TL;DR

Lovableアプリの安全性は、Supabaseデータベースの安全性そのもので、「RLSはオンになっている」だけではほとんど何も証明できません。2社の2人のユーザーでテストし、ロールをユーザーメタデータに置かず、シークレットキーをブラウザに出さないこと。さらにgrant、ビュー、ストレージ、関数、レート制限は、それぞれ別のやり方でポリシーをすり抜けるため、個別に確認します。以下に、私が実行しているリスト、LovableやClaudeに貼り付けられるプロンプト、そしてクライアントのLovableアプリで最初の12項目では見えないものが見つかったあとに加えた8つの確認を載せます。

Denysです。フルスタック・AIエンジニアです。今月、Lovableで作られたクライアントのアプリを直しました。いつものようにすべてSupabase上で動いていて、セキュリティの修正があまりに多く、アプリが修正でできているように感じるほどでした。これは、LovableやSupabaseのプロジェクトで顧客データを守れていると言う前に、いま毎回確認しているリストです。

Lovable は安全か?これまでのセキュリティ問題

プラットフォームはアプリをビルドしてホストしますが、アプリが安全かどうかは、主にその裏にあるSupabaseデータベースで決まります。アプリはブラウザからそのデータベースを直接読み書きするからです。公開されている記録には、両方の種類の問題が残っています。

時期 何が起きたか 出典
2025年3月 CVE-2025-48757:Lovableのショーケースアプリ1,645件をスキャンしたところ、170件で計303のエンドポイントが、RLSがないか緩すぎたために、公開キーを持つ誰にでもデータを返していた Matt Palmerによる開示、2025年5月
2026年2月 Lovableでホストされた1つのアプリが、18,697件のユーザーレコードを公開していた。関数内のアクセスチェックが匿名の訪問者を通し、ログイン済みのユーザーを弾いていた。Lovableは、コードの一部はLovableが生成したものではないと述べた The Register、2026年2月27日
2026年2月3日〜4月20日 プラットフォームのリグレッションにより、ほかのLovableユーザーが公開プロジェクトのチャット履歴とソースコードを読めた Lovableのインシデント投稿、2026年4月22日

最初の2つはアプリレベルの穴で、以下の確認で見つかる種類のものです。3つ目はLovable自身の問題で、Lovableが修正しました。あなたの役目は、キーをチャットやコードに置かないこと(確認3)と、そこにあったキーをローテーションすることです。Lovableは公開前にセキュリティスキャンを実行しますが、その結果に対応するのはあなたで、CVEが公開された時点のスキャンは、ポリシーが正しいかではなく、ポリシーが存在するかを確認するものでした。つまり「Lovableは安全か」は、あなたのデータベースが以下の確認を通るかどうかに行き着きます。

Lovableアプリで「RLSはオン」だけでは足りないのはなぜ?

Lovableアプリは、ブラウザからデータベースに直接アクセスします。間に自前のバックエンドはありません。ページが公開キーでSupabaseのData APIを呼び出し、何を返すかはPostgresが決めます(そのAPIがどのスキーマを提供するかは別の設定で、Supabase Exposed schemasで扱っています)。つまり、顧客データを守るルールはすべてデータベースの中にある必要があり、そのデータベースには入口がいくつもあります。

row level securityはその1つです。grantはロールがそもそもテーブルに触れるかを決め、ポリシーはどの行かを決めます。そして3つのものはポリシーを完全に飛び越えます。シークレットキー、security_invokerなしで作ったビュー、SECURITY DEFINER関数です。これは仮の話ではありません。Lovable自身のセキュリティガイドは、Lovableが設定するRLSポリシーを自分で確認するよう求めていますし、上のCVE-2025-48757は、誰も確認しなかったときに起きることです。

リクエストがデータに届く4つの経路。RLSポリシーが確認するのは最初の1つだけ。通常のリクエスト:ブラウザ → Grant → RLSポリシー → 自分の行、Edge Functionのシークレットキー:ブラウザ → Edge Function → シークレットキー → 全行、security_invokerなしのビュー:ブラウザ → ビュー → 所有者の権限 → 全行、SECURITY DEFINER関数:ブラウザ → RPC → 所有者の権限 → 全行。
図1. ポリシーが確認するのは最初の経路だけです。残る3つはRLSを飛び越える権限で動きます。出典:RLSとAPIの保護に関するSupabaseドキュメント、2026年10月。

プロンプト:LovableかClaudeに貼り付ける

そのまま貼り付けて構いません。そのあと、結果は自分で確認してください。AIは終わっていないのに「完了」と言いがちで、エラーを消すプロンプトは、アクセスを広げることでそれを実現してしまうことがあります。

このプロジェクトのセキュリティを監査し、見つかった問題を修正して、何を変えたか教えてください。

1. 2社の2人のユーザーでテストする。ユーザーAがBのレコードを、IDを指定して、API、検索、エクスポートで読む、編集する、削除することを試す。すべて失敗しなければならない。
2. すべてのテーブルでRLSを有効にし、ポリシーは行の所有者または会社を確認する。「ログイン済み」だけでは不十分。
3. service role keyはフロントエンドに絶対に置かない。これを使うEdge Functionは、ユーザーの確認と会社での絞り込みを自分で行う。キーはRLSを飛び越える。
4. ロール、プラン、会社IDをユーザーメタデータから取らない。ユーザーが編集できるため。サーバーだけが書き込めるテーブルに置く。
5. リクエストが2つのID(プロジェクト + タスク)を送ってきたら、そのタスクがそのプロジェクトに属するか確認する。
6. anonとauthenticatedからすべてのテーブル権限を取り消し、ポリシーに必要なものだけを付与し直す。RLSはTRUNCATEを止めない。テーブルを作るマイグレーションは必ずGRANTを含める。2026年10月30日から、Supabaseは新しいテーブルに自動ではgrantしなくなり、アプリは42501エラーになる。
7. すべてのビューをsecurity_invoker = trueにする。そうしないとRLSを飛び越える。
8. ストレージのバケット:ユーザーは自分のファイルだけを読み書きできる。非公開データに公開バケットを使わない。
9. SECURITY DEFINER関数:NULLは許可ではなく拒否を意味しなければならない。
10. ログイン、サインアップ、パスワードリセット、OTPにレート制限を設け、メモリではなくDBに保存する。
11. すべての入力をサーバー側で検証する。
12. Supabaseのsecurity advisorを実行し、すべての警告を修正する。

12の確認と、それぞれの証明方法

1. 2社の2人のユーザー

会社1にユーザーA、会社2にユーザーBを作り、それぞれにいくつかのレコードを持たせます。Aとしてサインインし、Bのレコードを、IDを指定して、API、検索、すべてのエクスポートで読み、編集し、削除してみます。どの試みも、何も返さないかエラーにならなければなりません。これは、以降のほとんどを捕まえる唯一のテストです。書いたものではなく、攻撃者が実際にやることを試すからです。

2人のユーザー、2つの会社。会社1のユーザーAが会社2のレコードを狙う。どの経路も何も返さないこと。APIでIDを指定:ユーザーA → BのレコードID → 読む・編集・削除 → 0行またはエラー、検索経由:ユーザーA → Bの文言を検索 → 結果 → Bの行なし、エクスポート経由:ユーザーA → エクスポート → ファイル → Bの行なし。
図2. 2社テスト。会社2のデータがどの経路からも返らなければ合格です。

両方の会社に所属する3人目のユーザーも加えてください。「呼び出し元は行の会社のメンバーである」という形のポリシーでは、そのユーザーは会社1で作業しながら会社2の行を読めます。別のSupabaseアプリで、そのようなユーザーでプローブを実行したところ、クエリがリクエストの会社でも絞り込むようになるまで、もう一方の会社のドキュメントが返りました。これはRLSが分離できなかった、2つの組織に属するユーザーとしてまとめています。

2. すべてのテーブルでRLSを有効にし、所有者または会社で絞る

auth.uid() is not nullは「サインインしている任意のユーザー」を意味し、公開のサインアップページがあれば、それは誰でもという意味になります。各ポリシーは、行の所有者または会社を呼び出し元と比較します。RLSがまだオフのテーブルを一覧にします。

select c.relname
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind in ('r', 'p') and not c.relrowsecurity;

3. シークレットキーをフロントエンドに絶対に置かない

service role keyと、新しいsb_secret_キーは、すべてのポリシーを飛び越えます。ビルド済みのJavaScriptから両方を検索し、一度でも公開されたキーはローテーションしてください。これを使うEdge Functionは、ユーザーが誰かの確認と会社での絞り込みを自分で行う必要があります。このキーでは、他に誰もやってくれないからです。

4. ロール、プラン、会社IDをユーザーメタデータから取らない

raw_user_meta_dataは、クライアントがsignUpに送ったものそのままで、ユーザーは後から変更できます。別のアプリでは、新規ユーザーのトリガーが招待されたロールをここから読んでいたため、登録した誰もがオーナーロールを渡してOwnerになれました。ロール、プラン、会社IDは、サーバーだけが書き込むテーブルかapp_metadataに置きます。詳しい経緯はサインアップのメタデータから取られたロールにあります。

5. 1つのリクエストに2つのID:両者が一緒に属するか確認する

リクエストにプロジェクトIDとタスクIDが含まれるとき、プロジェクトを認可するだけでは足りません。プロジェクトAの権限を持つユーザーが、AとあわせてプロジェクトBのセクションIDを送ると、その行はBに入ります。RLSはこれを捕まえません。INSERTは攻撃者自身のプロジェクトIDを書き込み、それはまさにポリシーが確認するものだからです。書き込む前に、子が親に属しているか確認してください。

6. grant:取り消し、必要なものだけを付与し直す

grantとポリシーは別々の層です。ポリシーを追加してもgrantは取り消されません。だからSupabaseのRLSガイドは、明示的にrevokeして付与し直すよう勧めています。別のプロジェクトでは、supabase/postgres 17.6.1のイメージが、publicのすべてのテーブルについてanonとauthenticatedにTRUNCATE、REFERENCES、TRIGGER、MAINTAINを付与しており、TRUNCATEにはどのポリシーも適用されません。Data APIにTRUNCATEのエンドポイントはないため、1回のリクエストで消せるわけではありませんが、どのクライアントロールにも必要ない権限であり、そのロールでSQLを実行する関数はそれを引き継ぎます。

revoke all on public.orders from anon, authenticated;
grant select, insert, update on public.orders to authenticated;

select grantee, table_name, privilege_type
from information_schema.role_table_grants
where table_schema = 'public' and grantee in ('anon', 'authenticated')
order by table_name;

1つの日付がこの確認を変えます。Supabaseは新しいテーブルに自動でgrantしなくなります。新規プロジェクトは2026年5月30日から、既存のすべてのプロジェクトは、Lovableが作成したものも含めて2026年10月30日からです。その後は、Lovableやマイグレーションが明示的なGRANTなしで作ったテーブルはアプリから見えなくなります。リクエストは42501で失敗し、そのテーブルを使う機能が壊れます。そのため、テーブルを作るすべてのマイグレーションは、enable row level securityやポリシーと並べて、grantも含める必要があります。

変わらないことが2つあります。既存のテーブルは今あるgrantを保持するので、不要な権限を取り消すのは引き続きあなたの仕事です。また、この変更で取り消されるのはselect、insert、update、deleteだけです。Lovableのプロジェクトでは、新しいデフォルトでも、新しいテーブルにはanonとauthenticated向けのTRUNCATE、REFERENCES、TRIGGER、MAINTAINが付いてきました。先にすべてrevokeしてからgrantする順序が、引き続き安全です。

7. すべてのビューにsecurity_invoker = true

ビューは所有者の権限で動き、Supabaseでは所有者が通常postgresなので、保護されたテーブルの上のビューは、ポリシーが止めているすべての行を渡してしまいます。Postgres 15以降では次のようにします。

alter view public.customer_summary set (security_invoker = true);

8. ストレージ:自分のファイルだけ、非公開データに公開バケットを使わない

公開バケットは、URLを知っている誰にでも任意のファイルを返します。非公開ファイルは、storage.objectsに所有者または会社を確認するポリシーを付けた非公開バケットに置きます。多くの場合はパスの最初のフォルダから判定します。プロジェクトを移した後は、有効なストレージポリシーの数を数えてください。あるアプリでは、移行後にすべてのアップロードが失敗しました。ポリシーのマイグレーションが実行されておらず、テーブルはRLSがオンでポリシーが0件だったからです。

9. SECURITY DEFINER関数:NULLは拒否を意味する

PL/pgSQLでは、if not (has_permission or assignee_id = auth.uid())は、assignee_idがnullのとき例外を出しません。条件全体がnullになり、ifはそれをスキップします。同じ式は、nullを拒否として扱うポリシーの中では安全です。あるアプリでは、これにより、すべての権限をオフにしたロールが、3683件すべてのタスクを操作できました。どのタスクにも担当者がいなかったからです。条件をcoalesce(…, false)で包んでください。詳細はPL/pgSQLのIF NOTでNULLがチェックを飛ばしたにあります。

10. データベースに保存するレート制限

ログイン、サインアップ、パスワードリセット、OTPを制限し、カウンターはPostgresに保存します。そのLovableアプリでは、制限がEdge Function内の変数にありました。ホスト版のプロジェクトを2026年10月17日にプローブしたところ、すべてのリクエストが新しいisolateで実行されるため、メモリ上のカウンターは常に1で、制限が作動することはありませんでした。ローカルのsupabase functions serveはisolateを再利用するので、同じコードがローカルのテストをすべて通りました。

カウンターの置き場所で効くか決まる。ホスト版Supabase Edge Functionのレート制限。関数のメモリ上のカウンター:リクエスト → 新しいisolate → カウンター = 1 → 制限されない、Postgres上のカウンター:リクエスト → claim_rate_limit → 共有の行 → 上限で429。
図3. ホスト版Supabase Edge Functionでの私のプローブ、2026年10月17日。すべてのリクエストが新しいisolateで始まりました。

保存先と同じくらい、キーも重要です。ホスト版では、X-Forwarded-Forの最後のホップはSupabase自身のプロキシなので、それをキーにすると全員が同じバケットに入ってしまい、x-client-ipはクライアントが設定できます。私のプローブでは、cf-connecting-ipが実際のクライアントを示していました。修正は、service roleだけが実行できるSECURITY DEFINER関数claim_rate_limit(bucket, key, max, window)で、アドバイザリロックで直列化し、生のIPやユーザーIDではなくそのハッシュを保存するものでした。

11. すべての入力をサーバー側で検証する

フォームが何を確認していても、APIはクライアントが送るものを何でも受け付けます。長さ、形式、enum、所有関係は、データベース(制約、トリガー)かEdge Functionで確認し、Reactだけで済ませてはいけません。

12. Supabase Security Advisor:すべての警告

Advisorsを開き、Security Advisorを選んで、すべての指摘を修正します。RLSがオフのテーブル、RLSを飛び越えるビュー、ユーザーメタデータを読むポリシー、許容的なusing (true)ポリシー、search_pathが変更可能な関数を指摘してくれます。確認1、5、9、10は見えません。これらは設定ではなくロジックの問題だからです。

最初の12項目では見えないことを、Lovableアプリは何で教えてくれた?

これらはクライアントのアプリとそのレビューから出てきたものです。どれもスキャナーには現れません。

  1. クライアントが設定してはいけないフィールド。 クライアントがstatus: 'approved'を自分で送り、モデレーションを飛び越えました。with checkがusingより緩いUPDATEポリシーでも、ユーザーが行を別の所有者に移せます。ポリシーは値を計算できないので、BEFORE INSERT OR UPDATEトリガーが、status、ロール、価格、会社をサーバー自身のデータから再計算し、クライアントが送った値を無視します。
  2. メインのINSERTだけでなく、すべての書き込み経路。 メインのINSERTを直した後も、同じ抜け道が添付ファイル、コメント、論理削除したアイテムの復元、カウンターに残っていました。最初の1つを直す前に、保護された列に書き込むものをすべて洗い出してください。
  3. 拒否リストではなく許可リスト。 偽造されたレコードは5回のレビューを要しました。修正のたびに1つの組み合わせを塞いでも、似た組み合わせが開いたままだったからです。typeとstatusの組み合わせのうち一覧にあるものだけを許可するトリガーが、クラス全体を一度に塞ぎました。
  4. 関数もAPI。 publicのすべての関数は、EXECUTE権限を持つ任意のロールが/rpc経由で呼び出せ、RLSは関数には適用されません。すべてのSECURITY DEFINER関数にset search_path = ''を付け、public、anon、authenticatedからEXECUTEを取り消し、必要なロールにだけ付与します。
  5. EXECUTEの取り消しが公開ページを壊すことがある。 Postgresは、そのロールに適用されるすべてのポリシーが使うすべての関数のEXECUTEを、実行されない分岐の中であっても確認します。anonがis_admin()のEXECUTEを失った途端、すべての匿名の読み取りが42501で失敗しました。管理者専用のポリシーはto authenticatedにし、公開の読み取りには管理者の分岐を含まない専用のポリシーを用意します。
  6. メール確認がオンのサインアップ。 確認がオンだと、signUpはセッションを返さないので、その直後のプロフィール書き込みはanonとして実行され、何も書き込まれません。しかもエラーも出ません。Lovableのプロジェクトは自動確認がオンのことが多く、そのせいでバグが隠れます。フィールドはsignUp({ options: { data } })で渡し、新規ユーザーのトリガーに検証と書き込みをさせ、そのメタデータは信頼できないものとして扱います(確認4)。
  7. 誤った理由で通るテスト。 ローカルのスタックとホスト版のプロジェクトでは、デフォルトの権限が違っていました。そのため「anonはINSERTできない」がローカルで通ったのは、RLSが働いたからではなく、grantがなかったからでした。否定のテストが意味を持つのは、肯定側のgrantが存在するときだけです。ローカルと本番でpg_default_aclを比較してください。
  8. マイグレーションファイルではなく、稼働中のデータベース。 マイグレーションやプロジェクトの移行の後は、実際に稼働しているものを比較します。pg_policies、各ガードのpg_get_functiondef、そして上記のgrantです。別のアプリでは、新しいマイグレーションが古い関数の本体をコピーし、後の修正を黙って元に戻していました。

LovableアプリはGDPRに準拠している?

アプリがGDPRに準拠するかどうかをLovableが決めるわけではありません。決めるのはアプリが個人データをどう扱うかで、セキュリティレビューではそこに答えが出ません。Lovable Cloudのアプリで私が確認する6つの抜けと、それぞれの直し方です。

  1. 処理者をすべて洗い出す。 Lovable Cloudはデータベース、認証、ストレージ、関数をSupabase上で動かし、Lovableはサイトをホストしてサインインメールも送れます。Lovable AI Gatewayはテキストをモデルの提供元に渡します。関数が呼び出すメール、AI、スクレイピングのサービスもすべて加えてください。個人データを受け取るサービスにはそれぞれデータ処理契約(DPA)が必要で、EU域外のサービスには移転の根拠も必要です。ホスティングのリージョンはコードに出てこないので、問い合わせてください。
  2. コンテンツを持つユーザーを削除してみる。 そのユーザーが書いたものからの外部キーが削除を止めることがあり、そうなると削除請求に応えられません。外部キーをcascadeかset nullにし、ユーザーのメールログとログイントークンも消す削除トリガーを追加し、そうしたユーザーを削除するデータベーステストを残してください。
  3. 保存期間を決める。 メールログ、閲覧の記録、失敗したメールのキュー、論理削除した行は、個人データを期限なしで持ち続けがちです。論理削除した行を一定期間後に物理削除し、そのファイルも消す定期ジョブで、ほとんどが片付きます。
  4. メールの開封計測は同意制にする。 受信者ごとに開封とクリックを記録するには、ePrivacy規則のもとで同意が必要です。同意フラグは既定でオフにし、タイムスタンプはクライアントではなくサーバーが設定します。
  5. 関数のログにアドレスを残さない。 Edge Functionのログは自動でマスクされないので、受信者のアドレスを含むconsole.logはそのアドレスを残します。IDと件数を記録してください。
  6. メール確認をオンにする。 自動確認がオンだと、誰でも自分のものではないアドレスで登録でき、実在する人のアドレスがアプリに入ります。登録側の対応は上のリストの6で扱っています。

自分のアプリを確認するには、関数内のAPIキーを探してサービスを洗い出し、コンテンツを持つテストユーザーを削除し、関数のログで@を検索し、各提供元にデータ処理契約を求めてください。これは法的助言ではありません。

再発をどう防ぐ?

上の確認はすべてクエリかテストなので、リポジトリに入れ、デプロイのたびに実行してください。SupabaseのpgTAPテストでgrant、ポリシー、関数をカバーできます。2社テストはその隣に置きます。アプリにロールと権限画面があるなら、ポリシーごとのテストでも足りません。私は、権限のチェックボックスを1つずつ本番のコピーに対して証明しています。その方法はSupabase RLSのテスト方法にあり、87のチェックボックスのうち30が何もしていませんでした。無料のSupabase セキュリティチェックは、プロジェクトのロール、grant、ポリシー、認証設定に対して46件のOWASP Top 10:2025ルールを実行するので、最初のチェックに使ってください。2社テストと、UIに関することは手作業のままです。

このリストがカバーしていないもの:決済とWebhook、Git履歴の中のシークレット、ホスト側のセキュリティヘッダー、バックアップ。どれも重要ですが、別のリストになります。

引き継いだLovableやSupabaseのアプリが、これらのうちどれを通過するのか分からない場合、この監査がプロジェクトの救出の出発点です。監査法人の工数管理の事例で、本番アプリのロール確認がどのようなものかを見られます。

このリストにない穴を見つけましたか?

ぜひ聞かせてください。LovableとSupabaseのアプリを守る方法について、情報交換しましょう。

お問い合わせ

© 2026 Denys Zaichenko. 元の記事: https://madsalt.tech/ja/blog/lovable-app-security-checklist/