🍆

Supabaseの .rpc() を活用して SELECT の取得範囲を制御する方法を考える【暴力的なスクレイピングの対策をしよう】

2023/10/14に公開
4

https://zenn.dev/masa5714/articles/40883d972ab2c7

上記は数日前に書いた「SupabaseでフロントエンドでSELECTを使うのはやめた方がいいかもしれない。スクレイピング対策しないとかなりマズイ」という内容の記事です。まずは上記記事をご覧頂いてから本記事をお読みください。

この問題の解決策を考えてみましたので記事として共有します。(ポスグレ初心者で手探りで触ってるので、セキュリティ的にマズイなどの指摘してください!)

2023/10/16追記-->

【追記】Max rowsの設定で20件前後にしておけばいいだけでした...。【部分的に問題解決】

散々騒いでおいて申し訳ないのですが、Supabaseの Project Settings -> API -> Max rowsの設定で呼び出し件数を制御することができます。これを20や30ぐらいに抑えておけば、大量データ取得されることは無さそうです。

※ローカル環境のSupabaseのGUIではこの設定項目は表示されません。tomlファイルから変更する必要があります。

※テーブル毎にMaxRows設定の件数を超えて取得したい場合は、この記事の内容は役に立つかもしれません。

※テーブル構造がバレてしまうのが嫌な場合もこの記事が役に立つかと思います。カラム名でデータベース初心者感が出てしまうはずなので、攻撃者のモチベーションを高めることになるんじゃないかと妄想しています。あっ、こいつ雑魚だ!と思われてしまったら一生懸命に穴探してきそう。(知らんけど。)

<--追記終了

データ取得系の処理は .rpc() 経由で行う方がいい。

.rpc() とは、PostgreSQLに登録した関数を実行するためのものです。

まずは .rpc() のおさらい

PostgreSQLの例
CREATE FUNCTION hello_world() RETURNS text AS
$$
BEGIN
  RETURN 'Hello world';
END;
$$
LANGUAGE plpgsql;

この関数を .rpc() 経由で動かす場合、下記のように書きます。

Next.jsで動かす例
import { createBrowserClient } from "@supabase/ssr";

...中略...

const supabase = createBrowserClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);

const { data } = await supabase.rpc("hello_world");
console.log(data);

console.logでは、PostgreSQLの hello_world() 関数の実行結果である「Hello world」が出力されます。

関数を実行するだけなので戻り値を自分で制御できる

フロントエンドで SELECT を実行すると、フロント側で欲しいデータを指定したり、件数を指定したり、かなり自由度が高すぎて第三者による不正なデータ取得の懸念があります。

ところが、 .rpc() 経由で関数を実行するのであれば、自分が関数を作り戻り値を定義するわけですから、一括でデータを取られるような暴力的なスクレイピングから身を守れると思いませんか?

僕はそう感じたので、データ取得系は全て .rpc() 経由で行おうと考えた次第です。

auth.uid() = id のようにユーザー自身のデータだけが取得できるようなものについては .rpc() で取得する必要は無いかなと思います。他ユーザーのデータを持っていかれることはないので!

.rpc() 使うのはわかったが、具体的に何をすればいいの?

それでは、実際に何に気をつけていけばいいか、何をすればいいかを見ていきましょう!

1. テーブルの RLS のポリシーで SELECT オペレーションを問答無用で FALSE を指定しちゃおう!

シンプルに SELECT のクエリを全て拒否してしまいましょう。
上記のように設定すると、フロントエンドから .from('users').select(*) などのリクエストが来てもデータを返さなくなります。

これで暴力的なスクレイピングを阻止できるようになりましたが、
今度はデータ取得ができなくなってしまったので、それを解決していきましょう。

2. rpc() で使う用のPostgreSQL関数を作ろう!

※下記の関数はただの書き方の例です。
 ガバガバなので実際に使うのは絶対にやめてください。

public.usersからデータを取得する例
CREATE OR REPLACE FUNCTION public.get_the_user()
    RETURNS TABLE (user_name text) -- user_name カラムの値を返してくれる
    LANGUAGE plpgsql
    SECURITY DEFINER SET search_path = public -- 関数登録を行ったユーザーと同じ権限で実行される
AS $function$
BEGIN
    -- publicスキーマのusersテーブルのuser_nameカラムのデータを取得
    RETURN QUERY SELECT users.user_name FROM public.users LIMIT 1;
END;
$function$;

このような感じで関数を作成します。

ポイントは SECURITY DEFINER という記述です。
この記述が無いと、ポリシーがそのまま効いてしまい .rpc() 経由であってもデータ取得ができません。フロントエンドから関数を実行する際に、public.get_the_user() 関数を作ったときのPostgreSQLユーザーと同じ権限で実行することを許可する記述です。

また、 SET search_path = public によって public スキーマ以外へのアクセスを制限することで比較的安全になります。

おわり

これで一括取得するような暴力的なスクレイピング対策をしつつ、フロントエンドだけでデータ取得ができるようになったかなと思います!

INSERT や UPDATE、DELETEについてはこの方法ではなく、Supabaseが用意してくれている方法で実行すればいいと思います。(ポリシー設定はしっかり!)

バックエンドと組み合わせればこんなこと考えなくてもいいのですが、フロントエンドだけで済ませたいという欲求には勝てず考えた結果です!

指摘は大歓迎!

どんなことでも構いません。
重箱の隅をつつくような指摘でもなんでも大歓迎です!
とにかく一人で情報収集していると何が正しいのかが分からず不安なので、間違った指摘でも頂けると様々な視点で考えるきっかけになるのでドンドン欲しいです!

Discussion

smallStallsmallStall

こんにちは。記事の内容についてですが、Supabaseの考え方を知る上でとても大事だと感じました。記事にもありますが、SupabaseはRLSを効かせることを推奨しています。

問題の件についてはRLSを適切に設定すれば解決すると思います。
using句のところでid=auth.uid()とすれば、profilesテーブルのidがユーザーと同じレコードのみしか取得できないようになります。言い換えると、自分のレコードしか取得できないようになります。ここでidはprofilesカラムで、auth.usersのidカラ厶を参照しているものを指します。
idカラムに値を入れるのは、auth.usersテーブルに紐づくトリガーを使う方法などがあります。
詳しくはこちらをご参考ください。
https://supabase.com/docs/guides/auth/row-level-security#usage

masa5714masa5714

コメントありがとうございます!

ご指摘の通り、管理画面などの自分自身の投稿データを取得する場合には、ご提示頂いたポリシーで問題解決できます。

今回、この記事で僕が懸念しているのは「投稿一覧データ」などのログイン/非ログインに関わらず全てのユーザーが閲覧できるデータの扱いです。(記事で例として出した public.users が例として適切ではありませんでしたね...。)

最もわかりやすいのがブログ等の記事データ( public.articlesとします )です。記事データは全てのユーザーがデータ取得できる状態になければいけませんので、ポリシーで守ることが難しいと考えています。守れないので下記の記事のようにHeadersで Apikey を添えて Postmanなどを経由して select=* としてリクエストされてしまうと、public.articlesのデータが一瞬で抜かれてしまいます。

https://zenn.dev/masa5714/articles/40883d972ab2c7

Webページに出力している以上はスクレイピングされてしまうのは仕方ありませんが、あまりに一瞬で抜かれるのが嫌だなと思い、今回の記事を投稿した次第です。

また、Supabaseで作られたサイトで profiles テーブルに emailをコピーしているWebアプリケーションがいくつかありました。そのアプリケーションの中にはemailはWebページ上に出力しておらず、メルマガ等の用途でデータを保持しているようでした。emailなのでまだ大丈夫ですが、何も考えずに住所などの個人を特定できるようなデータを含めてしまうと、攻撃者に手軽にデータを抜かれてしまう可能性があると考えています。(実際にそれらのサイトでのユーザー数を知れたり、非公開のemailを抜き取ることができてしまいました。)

説明が難しく文章がまとまっておらず恐縮ですが、
イメージできますでしょうか?

smallStallsmallStall

なるほど、その場合ですとたしかに趣旨が違いますね。
詳細を教えていただき、ありがとうございます。
すでにご存知かもしれないですが、DBの取得行数に制限をかけるのは手としてあると思います。
設定はSupabaseにログインしていただいて、ダッシュボードのSettingsに移動してAPI settingsの中にあるMax rowsからできます。ただ、これだとテーブルごとに違ったlimitをかけるのは無理なので、そういった場合は今回の記事のような手法を使うことになりそうですね。

masa5714masa5714

すでにご存知かもしれないですが、DBの取得行数に制限をかけるのは手としてあると思います。
設定はSupabaseにログインしていただいて、ダッシュボードのSettingsに移動してAPI settingsの中にあるMax rowsからできます。

ありがとうございます。この方法知りませんでした!
この設定ができるなら十分ですね...!!

当初問題解決方法としてこの設定項目がないかずっと探していたのですが、ローカル環境のSupabaseのGUIで見ていたためか見つけられず、結果的に迷走してしまっておりました😭

仰る通りテーブル毎にlimitをかける場合はSECURITY DEFINERを付けた関数を作って呼び出す形になりそうですね。

すごく助かりました!.rpfc() ではなく、Max rowsを適切に設定して .from('hoge').select() で実装したいと思います。