Supabaseで「本人の記録だけ読める」を検査したとき、DBの6項目がすべてPASSでも、添付ファイルの6項目がFAILになる状態を作れました。
原因は、DBのRLSではなく、Storageバケットの公開設定です。公開バケットからファイルを取得できるのはSupabaseの仕様です。本人限定のファイルを置くつもりの場所が、設定変更で公開になっていないかを別に確認する必要がありました。
この記事では、手元の合成データで再現した結果と、同じ検査を設定変更後にも使う方法をまとめます。検査コードはMITのOSSとして公開しています。
自分のアプリでも更新前後を再検査したい方へ
共通ZIPと準備手順:Leak Regression Lab v0.1.3
Node22と、通常ユーザー2人がそれぞれ自分のダミー行・非公開ファイルを読めるテスト環境が対象です。依存パッケージのインストールやモデルAPIは不要です。
準備で止まった場合は、「環境 / ユーザー / ダミーデータ / 設定」のどこかだけでもコメントに残せます。認証情報や生データの共有は不要です。
DBの行と、ファイルを配る経路を分けた
ユーザーA・Bに、それぞれ自分用のテスト行と小さな非公開ファイルを1つずつ用意しました。
| 対象 | 持ち主 | 別ユーザー | 未ログイン(anon) | public URL |
|---|---|---|---|---|
| DBの既知の1行 | 期待した内容を読める | 読めない | 読めない | 対象外 |
| Storageの既知の1ファイル | 期待したバイト列を読める | 読めない | 読めない | 読めない |
これをA・B両方で行います。DBが3経路×2行で6項目、ファイルが4経路×2個で8項目、合計14項目です。ファイルはSHA-256、行は指定したテスト用の内容と照合します。
「持ち主も読めない」を先に除外するのが大切でした。データを作り忘れた、認証が間違っている、全員を拒否してしまった、といった状態を「漏れないからPASS」にすると、検査が成立していないことを見逃すためです。
Supabaseの公式ドキュメントでは、publicバケットのファイル取得・配信はアクセス制御を迂回することが説明されています。非公開のDB行を守れたという結果だけでは、この経路を確認したことになりません。意図的に公開する画像は、今回の非公開ファイルの契約には含めません。
実測:公開設定だけでファイル側が6FAILになった
Node22の共通チェッカー Leak Regression Lab v0.1.1 を使い、ローカルSupabaseで設定を変えて実行しました。
| 合成環境の状態 | PASS | FAIL | INCONCLUSIVE | 終了コード |
|---|---|---|---|---|
| 正常な設定 | 14 | 0 | 0 | 0 |
| DBの読み取り許可を広げる | 10 | 4 | 0 | 2 |
| ファイルの読み取り許可を広げる | 10 | 4 | 0 | 2 |
| バケットを公開する | 8 | 6 | 0 | 2 |
| DBを本人も含めて拒否する | 8 | 0 | 6 | 3 |
| 設定を修復する | 14 | 0 | 0 | 0 |
公開バケットの行では、DB側の6項目と、持ち主によるファイル取得2項目がPASSでした。一方、他人・未ログイン・public URLでのファイル取得がA・Bとも成功し、非公開のはずの内容を取得できた6項目がFAILになりました。
同じコードで「ユーザーが直接所有する構造」と「チームへの所属で所有する構造」の2種類を検査しました。変更したのは設定と合成環境で、構造ごとの専用チェッカーは作っていません。
測定条件はmacOS、Node22.23.4、Supabase CLI2.24.3、手元のDocker環境1つです。2構造×6状態の12実行で、168項目の結果を記録しました。内訳はアプリのHTTP応答160件と、本人の取得が成立せず未実行にした後続検査8件です。168件すべてが通信したという意味ではありません。
これは作者が作った2種類のfixtureの結果で、第三者2人の利用実績や、他者のアプリを検査した結果ではありません。
v0.1.1の全実測JSON(この測定の固定版)には、各項目・状態・終了コードと実際のリクエスト数を保存しています。
認証エラーや、よく分からない404をPASSにしない
判定を3つに分けました。
- PASS:指定した読み取り・拒否の期待を、そのテスト内容で確認できた。
- FAIL:禁止した相手・経路に、非公開のはずのテスト内容が実際に返った。
- INCONCLUSIVE:本人の取得が成立しない、認証に失敗した、応答が想定外、などで必要な観測が足りない。
HTTP403や404だけでは、意図したアクセス制御で拒否したのか分かりません。無効なJWTや未知の応答も判定不能として扱い、守れたことにはしません。具体的に認識する拒否応答はREADMEに記載しています。
pgTAPや既存のRLSテストを置き換える意図はありません。既存のDB内の検査に加え、Auth・PostgREST・Storageの実HTTP経路から同じテストデータを読み取る確認を追加する道具です。
自分の検証環境で試す
対象は、通常のテストユーザーがPostgRESTで自分の行を読めて、Storageに本人限定のファイルを持つアプリです。サーバー経由でのみDBを読む設計や、公開ファイルだけのアプリに、この前提を無理に合わせる必要はありません。
必要なものはNode22以上、許可されたSupabaseのテスト環境、異なる通常ユーザー2人、各人の既存のダミー行・非公開ファイルです。チェッカーはアプリデータの作成・変更・削除をしません。
1. 共通ZIPを展開する
試用ページとZIPからv0.1.3を取得します。外部パッケージのインストールや、検査中のLLM呼び出しはありません。Supabase側の通常の利用枠は使います。
新しく展開したツールのディレクトリで、既存セッション用の例をコピーします。
cp examples/test-session-config.json test-config.json
cp examples/session-env.example .env.test
2. 自分のテストデータへ設定を合わせる
| 設定 | 入れるもの |
|---|---|
origin |
自分のSupabaseテストプロジェクト |
actors |
A・Bのテスト用環境変数の名前 |
rows |
テーブル名、ID列、UUIDまたは文字列の行ID、期待するテスト内容、持ち主 |
objects |
非公開バケット、既知のオブジェクトパス、ファイルのSHA-256、持ち主 |
ownerはaかbです。設定例の全体にあるプレースホルダーを置き換えます。ファイルのSHA-256は、macOS/Linuxならshasum -a 256 file.txtで確認できます。
.env.testには公開クライアントキーと、Supabaseが発行した現在有効な2ユーザーのテスト用access tokenを手元で設定します。service-role/secretキー、refresh token、本番ユーザーの情報は使いません。これらの値や設定ファイルを、作者やコメント欄へ送る必要はありません。
Google・magic link・匿名サインインを使うアプリでも、既存のテストセッションの入力を選べます。取得方法は手順を参照してください。チェッカーがログイン画面を操作したり、ブラウザから認証情報を取り出したりする方式ではありません。
JWTのデコードだけで本人と信用せず、設定したプロジェクトのAuthへユーザーを照会し、ID・roleと2人のIDが異なることを照合します。公式getUserの説明もAuthサーバーへの問い合わせを区別しています。
3. 一度実行し、設定変更後に同じ条件で再実行する
node cli.mjs check --env .env.test --config test-config.json --out ./leak-check-before --live
# 設定変更後、同じテストデータ・契約で別の出力先へ
node cli.mjs check --env .env.test --config test-config.json --out ./leak-check-after --live
node cli.mjs compare --before ./leak-check-before/report.json --after ./leak-check-after/report.json
出力先は毎回新しいディレクトリにし、その親は既存のディレクトリを指定します。各出力のindex.htmlかreport.jsonで結果を見られます。終了コードは0が範囲内PASS、2が露出確認、3が判定不能を含む結果、1が入力・準備段階の拒否です。
比較ではテスト契約の指紋も照合します。期待する内容や対象を変えて、見かけ上FAILを消す比較にはしません。
10/29追記:列の権限を広げずに再検査する
v0.1.1には、判定に使わない列までSELECT *で要求する制約がありました。必要なID列・期待値の列だけを読めるアプリでも、本人の読み取りが403になっていました。v0.1.2は、設定したid_columnとexpectの列だけを重複なく取得します。
同じ合成データ・通常ユーザー・列権限で、実際のPostgREST応答を比較しました。テーブル全体のSELECTを外し、id・bodyだけを許可し、owner_idは読めない状態です。
| 列を制限した状態 | PASS | FAIL | INCONCLUSIVE |
|---|---|---|---|
| v0.1.1 | 8 | 0 | 6 |
| v0.1.2 | 14 | 0 | 0 |
| v0.1.2でRLSだけを意図的に広げる | 10 | 4 | 0 |
| 同じ列権限のままRLSを戻す | 14 | 0 | 0 |
旧版は本人2行が403で、後続4項目を実行していません。新版でも必要な列が本人から読めなければ判定不能です。元のテーブル権限での対照も含む5回では、70検査予定・HTTP応答66件・未実行4件でした。最後に検証用の権限とRLSを元へ戻しています。実測条件と更新手順と全結果JSONを公開しています。
対象は宣言した列で、未指定の列や列権限全体を監査する機能ではありません。取得条件が変わるため、v0.1.1とv0.1.2の結果の比較は拒否します。新版で基準を取り直し、同じ版・同じ契約で更新前後を比較してください。冒頭の既存v0.1.1実測は過去の結果として残しています。
10/29追記:正常な匿名拒否と、認証失敗の401を分ける
匿名ロールにテーブルのSELECT権限を与えていない場合、PostgRESTは権限不足をHTTP401・SQLSTATE 42501で返すことがあります。公式のエラー対応表も、同じ42501を未認証401/認証済み403に分けています。
ただし、401だけを見て「他人を拒否できた」と扱うと、不正なJWTや本人の認証失敗まで安全判定に混ざります。v0.1.3では、所有者の取得が成功した後の匿名の行検査で、権限拒否と確認できる応答だけを認定します。所有者・別ユーザー・Storageの401や、不正署名は引き続き判定不能です。
同じローカル合成環境で測った8回のうち、主な対照は次のとおりです。アプリのテーブル権限を広げて通したわけではありません。
| 条件 | 版 | PASS | FAIL | INCONCLUSIVE |
|---|---|---|---|---|
| 匿名にテーブルSELECTなし | 0.1.2 | 12 | 0 | 2 |
| 同じ権限のまま | 0.1.3 | 14 | 0 | 0 |
| 匿名のJWT署名を不正にする | 0.1.3 | 12 | 0 | 2 |
| 匿名の権限は閉じたまま、行のRLSだけを広げる | 0.1.3 | 12 | 2 | 0 |
| 所有者もテーブルを読めなくする | 0.1.3 | 8 | 0 | 6 |
正常・復旧の対照も含む8回は、112検査予定、アプリHTTP応答108件、所有者の陽性対照が成立せず未実行4件でした。Authへのユーザー照会16件は別です。元の権限とRLSは復旧済みです。実測条件と全結果JSONで確認できます。
この差はチェッカーの拒否分類を直した結果で、アプリの脆弱性を修復した実績ではありません。拒否判定の契約も変わるため、0.1.2以前と0.1.3のレポート比較は拒否します。0.1.3で基準を取り直し、同じ契約で実際の更新前後を比較してください。 最新のホステッド環境のpublishable-key gatewayや、Google OAuth画面を実行確認した結果ではありません。
今回の範囲と、次に確かめたいこと
検査するのは、指定した行とファイルの読み取りです。DBやバケット全体を探索するスキャナーではありません。書き込み・削除・署名URLの発行や有効期限・キャッシュ・画像変換・Realtime・RPC・業務ロール・ブラウザのセッションは対象外です。1MiB超の応答も判定不能にします。
v0.1.1の実測は、通常のパスワード認証で先に発行したJWTを入力したものです。Google/OAuth画面、匿名ユーザー作成、最新のホステッド環境やネイティブWindowsまで動作確認したという結果ではありません。PASSもアプリ全体の安全性の証明にはなりません。
今確認したいのは、既にテスト環境がある開発者が、この共通版を設定だけで使えるかです。試せる場合は、利用版・PASS/FAIL/INCONCLUSIVE・準備で詰まった箇所だけでも教えてください。まだ実行できなくても、何を用意するのが重いかは改善の材料になります。認証情報、実データ、非公開パスの共有は不要です。