🔒

【Next.js + Supabase】フロントのselectを書き換えられてデータ流出?クイズアプリで学ぶ堅牢なセキュリティ設計

に公開

1. はじめに

個人でクイズアプリを開発するにあたって、問題、解答、解説等のデータをどのように安全に扱うかについて、開発時に直面した課題とそれを解決した実装方法を紹介します。今後、同じようなクイズや問題集のアプリを作ろうとしている方の参考になれば幸いです。

2. Supabaseにおけるデータ流出リスクの背景

2.1 フロントエンドから直接DBを叩ける特性と代償

SupabaseはPostgreSQLベースのBaaSで、フロントエンドから直接DBへクエリを投げられることがポイントです。具体的には、Next.jsのコンポーネントの中で以下のようなコードを書くだけで、簡単にデータが取得できます。

// フロントエンドのコードから直接SQLのようにデータを取得できる
const { data: questions } = await supabase
  .from('questions')
  .select('id, question, options');

中間にAPIサーバーをいちいち構築する必要がないため、開発が非常にスムーズになります。しかしこの「直接叩ける」という便利さの裏には、フロントエンド側にリクエストの主導権があるという構造的な特性があります。これが、次章で説明するデータ流出リスクの根本的な原因になります。

2.2 フロントエンドでのマスクではコンテンツの流出を防げない理由

クイズや問題集のアプリを作る際に最も避けるべきは「全問題の解答や解説データが意図しない形でユーザー側に丸ごと流出してしまうこと」です。
「フロントエンドに届いたデータをUI側で隠しても意味がない」——これはWeb開発をある程度経験した人なら当然わかっていることです。なので筆者も、APIの selectanswerexplanation を除いて取得するよう実装していました。Networkタブを覗かれても、流れているのは問題文と選択肢だけ。一見、対策は万全に見えます。しかしフロントエンドに主導権がある構造上、リクエスト自体をブラウザから書き換えて再送するというアプローチは容易に実行できてしまいます。

2.2.1 開発ツールを使ったリクエストの改ざん

Supabaseはフロントエンドから直接アクセスできる便利さの裏返しとして、ブラウザ側にAPIキー(Anon Key)やユーザーの認証トークン(JWT)を保持しています。これらはブラウザのNetwork(ネットワーク)タブに流れる通信ヘッダーを覗けば、誰でも確認できる状態です。

これを利用すると、ブラウザが裏で送信した「本物のリクエストコード(URL、正しいAPIキー、認証ヘッダーがすべて含まれた生の fetch コード)」が、開発者ツールから簡単にコピーできてしまいます。

// 本来の通信(Next.jsのコードが生成したもの)
fetch("https://xxxx.supabase.co/rest/v1/questions?select=id,question,options", { ... })

// コンソール上で勝手に書き換えられた通信
fetch("https://xxxx.supabase.co/rest/v1/questions?select=*", { ... })

開発者がNext.jsのコード上でどれだけ select('id, question, options') と綺麗に書いていようが、それはフロントエンド側の都合に過ぎません。

有効な認証キーを持った状態のまま、コンソール上から「隠されているはずの answer(解答)や explanation(解説)も一緒に含めてデータをよこせ」とクエリを直接書き換えられてしまうため、データベースにある解答・解説のデータが丸ごと引き出されてしまうリスクが生じます。
なお、この改ざんはJavaScriptの変数空間ではなくHTTPレイヤーで行われるため、クライアントのカプセル化では防げません。

2.2.2 テーブル単体のRLS(Row Level Security)では防げない理由

テーブルにRLSを設定しても、このカラムの絞り込みをバイパスするデータ取得は防げません。
なぜなら、アプリの性質上、一般ユーザー(anonロール等)に対して「問題データを取得するために、その行(Row)を読み取る権限」は許可する必要があるからです。

Supabaseの基本機能であるRLSは、あくまで「その行(Row)にアクセスしていいか?」を判定する仕組み(行レベルセキュリティ)です。

厳密には、PostgreSQL自体にはカラム単位のGRANT制御の仕組みも存在しますが、これをSupabaseの自動生成API(PostgREST)と組み合わせて運用しようとすると、スキーマキャッシュの管理やエラーハンドリングが非常に複雑になるため採用しにくいです。

つまり、ユーザーがクイズを解くために「その行を読んでいいよ(USING true)」と一度許可を出してしまったら、同じ行の中に question(問題文)と answer(解答)が同居している限り、通常のRLSは「この行を読む権限があるから、どのカラムを要求されてもどうぞ」と、すべてのデータを流してしまいます。

結局、解答や解説の流出を完全に防ぐためには、フロントエンドのコードを工夫するのではなく、「ユーザーがアクセスできるエンドポイント(オブジェクト)には、物理的に正解データが存在しない構造」を、データベース(Supabase/PostgreSQL)のレイヤーで強制するしかありません。

そこで登場するのが、今回の主役である「View(ビュー)」を活用した防御策です。

3. 【解決策1】View(ビュー)を活用したデータ隠蔽

データベース(PostgreSQL)のレイヤーで「物理的に正解データが存在しない構造」を作り出す最もスマートな方法が、View(ビュー)の活用です。

実体のあるテーブル(questions)には解答や解説を含めたすべてのデータを保持しておき、フロントエンド(ユーザー)がアクセスする専用の「窓口」として、特定のカラムだけを削ぎ落としたViewを作成します。

  1. 元となるテーブルの作成
    まずは通常通り、すべてのデータを持つマスターテーブルを作成します。ここではポリシーを作成せず、RLSを有効化するだけにとどめます。これにより、anonを含む一般ユーザーはこのテーブルに直接アクセスできない状態になります。

    -- マスターテーブルの作成
    CREATE TABLE questions (
        id SERIAL PRIMARY KEY,
        question TEXT NOT NULL,       -- 問題文
        options JSONB NOT NULL,       -- 選択肢の配列
        answer INT NOT NULL,          -- 正解のインデックス(隠したいデータ)
        explanation TEXT NOT NULL     -- 解説(隠したいデータ)
    );
    
    -- テーブルのRLSを有効化
    -- ※ ここにポリシー(CREATE POLICY)を作らないことで、
    -- anonやauthenticatedからはデフォルトで「全拒否(アクセス不可)」状態になる
    ALTER TABLE questions ENABLE ROW LEVEL SECURITY;
    
  2. フロント公開用のViewを作成する
    次に、フロントエンドに流しても良いカラム('question' と 'options')だけを抽出したViewを定義します。

    -- フロントエンド公開用のビューを作成
    CREATE VIEW questions_public AS
    SELECT 
        id,
        question,
        options
    FROM 
        questions;
    

    このように定義することで、questions_public というViewには、そもそも answerexplanation というカラム自体が物理的に存在しない状態になります。

開発者がフロントのコード側でどれだけカラムを絞り込んでいようが通信の改ざんで突破されてしまいますが、データベースのViewのレイヤーで物理的にカラムを削っておけば、悪意のあるユーザーが開発者ツールからリクエストを select=* と書き換えて再送したとしても、400 Bad Request が返るだけです。また、Viewを経由せずマスターテーブル(questions)を直接叩こうとした場合も、anonへのSELECTポリシーが存在しないため 200 OK で空配列([])が返るだけで、データは一切取得できません。本番環境で実際にJWTを用いた検証を行い、どちらの攻撃手法でも解答・解説データの取得が不可能であることを確認しています。

4. 【解決策2】RPCによる回答判定

前章のViewで answerexplanation を物理的に隠せましたが、クイズアプリである以上、ユーザーが回答した瞬間に「正解かどうか」「正解はどれか」「解説は何か」をどこかから取得しなければなりません。
「回答後なら取得可能」というポリシーをRLSで表現することは可能ですが、先述の通り行単位の制御であるRLSの性質上、answer カラムだけを選択的に解放することはできず、行全体へのアクセスを許可することになってしまいます。

この問題を根本から解決できるのが、PostgreSQLの ストアドプロシージャ(RPC) です。フロントエンドは選んだ選択肢のインデックスをサーバーに送るだけで、正誤判定はすべてサーバー側の関数が行い、その結果(正誤・正解インデックス・解説)だけが返ってきます。

// フロントエンド側のコード
const { data, error } = await supabase.rpc("submit_quiz_answer", {
    p_question_id: Number(currentQuestion.id),
    p_selected_answer: originalSelectedIndex,
});
CREATE OR REPLACE FUNCTION public.submit_quiz_answer(
    p_question_id bigint, 
    p_selected_answer integer
)
RETURNS TABLE(is_correct boolean, correct_answer integer, explanation text)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $function$
...
  -- questionsテーブルから正解・解説を取得し、送信された回答と突合
  -- 結果をuser_answersに記録したうえで返却
...
$function$;

関数は SECURITY DEFINER で定義されており、一般ユーザーは「呼び出す」ことしかできず、内部で questions テーブルを参照していることはユーザーには見えません。仮に問題IDを総当たりにしても、一問ずつ選択肢を送らなければならない構造になっており、まとめて解答を抜き出すことはできません。
(※なお、総当たりへの完全な対策にはレートリミット等も別途検討が必要ですが、主題から逸れるため本記事のスコープ外とします)

回答結果が返ってきたあと、フロントエンドはその内容をReactの状態(questionCache)に書き込んで表示します。回答前の questionCache には answerexplanation も存在しません。回答後に初めてサーバーから渡され、その瞬間だけ状態として保持される設計です。

ViewとRPCを組み合わせることで、二重の防御が成立します。回答前は questions_publicanswer がスキーマ定義上に存在せず、回答後は submit_quiz_answer を経由する以外に正解を得る手段がありません。解答・解説がフロントに現れるのは、ユーザーが実際に回答した、その問題・その瞬間だけです。

UIでデータを隠すのではなく、構造的に正解を取得できない状態を作る——この方針は、コンテンツを守るための対策であると同時に、回答して初めて結果が開示されるというクイズとして自然なUXとも一致しています。

5. おわりに

本記事では、Supabaseを使ったクイズアプリ開発で直面した「解答・解説データの流出リスク」と、それをデータベースのレイヤーで根本的に解決する方法を紹介しました。
ポイントをまとめると以下の通りです。

  • フロントエンド側でのカラム絞り込みは、リクエストの改ざんで突破できる
  • Viewで物理的に answer / explanation を存在させない構造を作る
  • 回答判定はRPC(SECURITY DEFINER関数)に閉じ込め、サーバー側だけが正解を知る

APIサーバーなしで開発できるBaaSの強みを活かしつつ守るべきコンテンツを保護するには、「見せたくないものはDBレイヤーで物理的に存在させない」という発想が核心です。同じような構成のアプリを作る方の参考になれば幸いです。

最後までお読みいただき、ありがとうございました。

Discussion