データ流出(?)の発見者になってしまった話。Supabaseをフロントエンドだけで実装するときはSELECTの扱いには十分注意!
▲ Donzubaというサービスを公開しました。(Supabase + Next.js App Routerで開発)リツイートで応援お願いします!
昨日、Donzubaというサービスを一旦公開したので、その件についてハッピーな記事を書こうと思っていました。しかし、先日 穏やかじゃない問題 を発見してしまったので、多くの方に見て頂いている貴重な機会に注意喚起をすることにしました!(Supabaseを愛する者としての使命感)
「穏やかじゃない問題」とは何なのか?
やや誇大表現を使うと 「Supabaseで作られた、数万ユーザー抱えるサービスでデータ流出」 を発見してしまいました。
そのサービスは、海外スタートアップ企業(←時価総額数兆円のグループの日本の某VCも出資している)で関東の自治体が誘致に成功し、新たに設立された日本法人が運営するサービスです。ユーザーが入力したプロンプトを元にAIが回答してくれる、というサービスでした。データのやりとりはフロントエンドから直接全て行っている様子でした。
プレスリリースも大量に打っており、急速にユーザーを獲得している様子でした。
そんな中、僕が見つけてしまった問題は下記の通りです。
- ユーザーの総数が判明してしまう。(数万規模のサービスで、報告文書いてる間に数百ユーザー増えるほどの勢いがありました。)
- ユーザーが入力したプロンプトとAIが回答した内容が見えてしまう。
- ユーザーのメールアドレスが見えてしまう。
などです。
これらの情報はそれほど重大なものではありませんが、
本来見えないデータが見えるという意味ではデータ流出と言ってしまっても良いのではないでしょうか。(住所などのヤバい情報を取り扱ってなかったのが救いですね。)理性が働き、見ちゃいけないものだと思ったのでプロンプトに個人情報が含まれていたかは未確認です。
この状態はサービス開始から半年ほど続いていたものと予想されます。
※問題は既に報告済みで、現在は対策もされています。
どうやってデータ流出を見つけたのか?
とても単純な方法でデータ流出を見つけてしまいました。
詳細な流れは下記記事をご覧ください。
簡単に説明すると、headers情報にApikeyをそのままコピーしてPostmanでリクエストを投げるという単純な方法です。単純な方法ではありますが、 パラメータを変更すると自由に取得したいデータ条件を指定できてしまう 強力な方法です。
offset や where句(eq.) を自由に指定できるので、 users
などのテーブル名が判明してしまえばループを回すだけでユーザーの総数が分かりますし、どんなカラム名で構成されているのかも見えてしまいます。
▲ ※今回の問題のサービスの中身ではありません。同じ方法で他のサービスでも同様に見れてしまうところが大半です。(こんな感じで丸見えでした。)
このサービスでは何が原因で起きた問題だったのか?
外部から見る限り、今回の問題は「RLSが適切に設定されていなかった」という初歩的なミスのようでした。恐らく、RLS設定のTarget rolesで authenticated にして USING expression を true にしていたものと思います。(認証ユーザーのheaders情報「Authorization」が無いクエリは弾かれたので。)まさかリクエストを投げられて見られてしまうことを想定していなかったのでしょう。
それよりも、僕が思うのは単純な設定ミスというよりは、「SELECT」の扱いへの姿勢にも問題があったと考えています。
SupabaseではSELECT文が簡単に書けてしまいます。
import { createBrowserClient } from '@supabase/ssr'
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
await supabase.from('users').select(`*`);
ご存知の通りたったこれだけの記述で users テーブルのデータを取得できてしまいます。また、取得した後のフロント側のアプリケーションのプログラミングに取り掛かりたくなってしまうので、RLSへの意識が薄くなってしまったのだと思います。
.rpc()
経由でデータを取得すべきだと考える。SELECTは基本的に使わない姿勢で開発に取り組む。
【持論】僕はSELECTを使うべきではなく、そこで、僕が考える対策案としては SELECTオペレーションのRLSは真っ先に「FALSE」にする。UPDATEやDELETEの必要があるデータについては「id=auth.uid()」でユーザー自身のデータだけが取得できる状態まで緩めてOK という姿勢です。(UPDATEやDELETEはSELECTできる状態でないと動かないため。)
基本的には取得できない。取得できたとしても自分のデータだけが取れるだけ。
これぐらいSELECTに対して厳しい姿勢で取り組んでいくと、万が一RLS設定に不備があってもデータ流出の可能性をグッと減らせると思いませんか?(悩むとすれば開発者がちょっと面倒になるだけ...辛い...が、データ流出と天秤にかけたら圧倒的にマシ。)
姿勢だけで自然と解決できる問題だと思っています。
僕が提案したいデータの取得の例
SELECTが使えない代わりに全て PostgreSQL の自作関数経由( .rpc()
)で取得していく必要があります。
CREATE OR REPLACE FUNCTION public.get_posts()
RETURNS TABLE ( -- 任意の値だけを返すようにできる
post_id uuid,
post_title text,
post_contents text,
post_datetime timestamp with time zone
)
LANGUAGE plpgsql
SECURITY DEFINER -- この記述によってポリシーがFALSEでもrpc経由なら取得できるようになる
SET search_path TO 'public'
AS $function$
BEGIN
RETURN QUERY -- 下記のSELECTの結果がASでつけた名前で返却される
SELECT
posts.id AS post_id,
posts.title AS post_title,
posts.contents AS post_contents,
posts.updated_at AS post_datetime
FROM public.posts
ORDER BY updated_at DESC
LIMIT 10;
END
$funciton$;
テーブルを構成する生のカラム名を隠蔽できますし、
開発者のコントロールできる範囲でデータを外部に流せるようになります。
また、運営の都合上に設定するかもしれない「priority」のような優先度を示すような指標があると心象悪くなるかもしれませんし。
(関数の書き方はAIさんに聞きまくりましょう!PostgreSQLなどの昔からある技術への回答はすごく優秀です!僕はデータベースの専門家ではありませんが、お陰様で何とか書けてます!)
この関数を使うときは下記のように使います。
import { createBrowserClient } from '@supabase/ssr'
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
const {data, error} = await supabase.rpc('get_posts');
関数を実行すると外部からはテーブル名が全く見えなくなります。
第三者である攻撃者は条件を指定しての任意のSELECTクエリを動かせなくなるので、比較的に安全になったと思いませんか?
僕はこっちの方が安全にできると思います。
ただし、 SECURITY DEFINER(関数を作成したユーザーの権限で実行される)
で動かすのでSQLインジェクション対策はしっかり行いましょう!
※Supabase Studioで関数書くの大変なのでDBeaverなどのソフトを使うとすごく書きやすいです。(予約語とかを自動で大文字にしてくれる機能があるので誤字に気づきやすい。)
SELECTをそのまま使っても問題ないシチュエーション
とはいえ、全く .select()
を使うべきではないとは言いません。
当然そのまま使っても問題ないシチュエーションがあります。
管理画面とマイページ周りです。
これらのページはユーザー自身のデータの取得が前提ですので、 id = auth.uid()
でRLS設定を緩めてあげて、 await supabase.from('hoge').select(
*)
で取得してしまっても問題ありません。
こういったデータは削除・更新の要件があるかと思います。UPDATEやDELETEはSELECTが効かないデータには使えないので必然的にRLS設定を緩めることになりますし!
この方法はスクレイピング対策としても使える
苦労して集めたデータを1リクエストで1000件ほどごっそり持っていかれたら嫌ですよね。僕が提案したこの方法なら スクレイパー(スクレイピングする人)側は取得条件を自由に変更できませんから、多少なりともスクレイピングのハードルになってくれます。 とはいえ、スクレイピングはあの手この手で最善な方法を見つける勝負なので、時間の問題ではありますが。全く対策しないのとではスクレイピングのしやすさは雲泥の差です。
標準機能でもそれなりに対策できる
この方法でなくてもSupabaseには暴力的なスクレイピング(一発で大量データを持っていくような行為)への対策はできます。
Project Settings -> API -> Max rows で1リクエストでの最大取得件数を変更できます。が、取得条件の制限はできませんのでご注意ください。
自分のサービスがデータ流出のリスクに晒されていないかチェックする方法
冒頭の この記事の方法 を使って自分のサービスがデータ流出してしまっていないかチェックできます。
Postmanで1つずつテーブルにクエリ投げて誰でも取得できるようになってないか確認しておきましょう!Postmanじゃなくても確認できますが、手軽に使えるのでPostman推奨。
ちょっとしたメリット
Next.jsなどのアプリケーション側とSupabaseのバックエンドを切り分けることができるので、ちょっとしたSQL文の修正をしたいときに、わざわざアプリケーションをビルドしてデプロイする必要がなくなります。
関数を修正して反映すればいいだけなので、カジュアルに修正しやすくなります。(良し悪しですが...。)
さいごに
Supabaseの利用者が増えてZennでも記事が増えると嬉しいです!
個人開発で得た知識はZennで積極的に発信しています。ぜひご覧ください!
みなさんもガンガン情報発信していきましょう(Supabaseは滅んでほしくないから...😢)
Supabase Advent Calendar 2023 の17日目はogikunさんの「supabaseのベクトルDB拡張機能とLangChainを使った検索機能」です。
【余談】
@supabase/ssr
のv0.0.10ではTwitter認証でユーザー名に @
や -
が入っているとログインできない事象がありました。非推奨ではありますが、現時点では @supabase/auth-helpers-nextjs
を使っておいた方が良さそうです。
▼ Issuesにて報告済み(AI純度100%の英語)
過去に書いたSupabase関連の記事のご紹介
▼ フロントエンドとPostgreSQLの文字数カウントを一致させる
▼ INSERTやUPDATEへのイタズラの可能性とその対策(トリガーbeforeをバリデーションとして活用する)
▼ユーザー削除処理の書き方の例
▼Windows勢が直面するビルド時のSupabaseのTypeScript型に引っかかる問題を回避する
▼GOD(最新情報を日本語で追おう!)
Discussion