
はじめに
「E2Eテストを動かしただけで、本番データベースが空になった」——これは仮定の話ではありません。
2026年5月11日、E2Eテストの前処理スクリプトが本番 Supabase に接続し、全ユーザーデータを削除するインシデントが発生しました。原因は、安全機構の条件分岐がコメントと正反対に実装されていたという単純なバグです。
Supabase Free プランには PITR(ポイントインタイムリカバリ)も自動バックアップもありません。データは復旧不可能でした。
本記事では、このインシデントの原因分析から、当日中に実施した5つの再発防止策までを時系列で記録します。E2Eテストのリセットスクリプトを持つすべてのプロジェクトに共通するリスクです。
何が起きたのか
タイムライン
| 時刻 (UTC) | 出来事 |
|---|---|
| 08:00:37 | npm run e2e 実行。E2Eリセットスクリプトが本番DBに接続 |
| 08:00:38 | DELETE FROM auth.users + 全テーブル TRUNCATE ... CASCADE が完了 |
| 08:05頃 | ユーザーのログイン失敗報告から異常検知 |
| 08:10頃 | MCP経由で auth.users を直接クエリし、データ消失を確認 |
| 同日中 | 再発防止の3本のPRをマージ + バックアップ基盤を構築 |
検知から原因特定まで約10分。Supabase MCP と Observability MCP を使った監査ログの即時確認が、この速度を実現しました。
根本原因:安全条件の反転
問題のコードはこうなっていました。
async function resetLocalDatabase(repoRoot, dbUrl) {
if (!isLocalSupabaseDatabaseUrl(dbUrl)) {
// コメント: 「リモートDBのときは安全側に倒す」
// 実装: リモートのときだけ truncate を実行する
await truncateUserState(dbUrl)
return
}
// ローカルのときは supabase db reset(安全)
}
!isLocal が true = リモートDB接続時に truncateUserState が呼ばれます。この関数は DELETE FROM auth.users を含む完全な破壊操作です。
コメントには「安全側に倒す」と書いてありましたが、実装はその逆でした。.env.local に本番DBのURLが混入した状態で npm run e2e を実行した瞬間、本番データが消えました。
なぜ防げなかったのか
3つの要因が重なっています。
- 条件分岐の設計ミス: 「リモートならスキップ」ではなく「リモートなら部分実行」という曖昧な分岐。部分実行の中身が
DELETE FROM auth.usersでは防御になっていません - 環境変数名への過信:
LOCAL_CONNECTION_STRINGという名前の変数に本番URLを入れる人は必ず現れます。名前は強制力を持ちません - バックアップ不在: Supabase Free プランに PITR も自動バックアップもないことを認識していながら、代替手段を用意していませんでした
再発防止:5つの防御層
インシデント当日から翌日にかけて、5つの防御層を構築しました。
第1層:positive assertion による入口封鎖
「リモートならスキップ」という否定形のガードを、「ローカルであることを確認してから実行」という肯定形のアサーションに置き換えました。
function assertLocalDatabaseUrl(dbUrl, source) {
if (isLocalSupabaseDatabaseUrl(dbUrl)) {
return // ローカルなら通す
}
const safeUrl = redactConnectionString(dbUrl)
throw new Error(
`[e2e] Refusing to use a non-local database: ${safeUrl} ` +
`(source: ${source}).`
)
}
エラーメッセージでは認証情報をマスクし、「どの設定ファイルから来た値か」を source 引数で特定できるようにしています。
ローカルホストの判定では、WHATWG URL API の落とし穴にも対処しています。postgresql:// は special scheme ではないため、IPv6 アドレスの hostname がブラケット付き([::1])で返ります。
function isLocalDatabaseUrl(dbUrl) {
try {
const url = new URL(dbUrl)
const hostname = url.hostname.replace(/^\[/, '').replace(/\]$/, '')
return ['127.0.0.1', 'localhost', '::1'].includes(hostname)
} catch {
return false
}
}
第2層:多重防御の配置
同じアサーションを、破壊的操作の5箇所に配置しました。
ensureLocalDatabaseUrl() ← URL取得時(第1の壁)
↓
resetLocalDatabase() ← リセット関数の入口(第2の壁)
↓
truncateUserState() ← DELETE/TRUNCATE の直前(第3の壁)
↓
startAuthMock() ← 認証モック起動時(第4の壁)
↓
getE2EDatabasePool() ← DB接続プール生成時(第5の壁)
リファクタリングで呼び出し経路が変わっても、最深部の関数が自衛します。1箇所のガードが迂回されても、残りの4箇所で食い止めます。
第3層:エラーメッセージの文脈分岐
同じバリデーションエラーでも、原因に応じて修正手順を変えます。
const hint = source.includes('.env.local')
? 'Remove CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING from .env.local'
: source.includes('process.env')
? 'Run `unset CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING` in this shell'
: 'Update the source to a local DB URL'
「.env.local に本番URLが入っている」「シェル変数から漏れている」「その他」の3パスに分岐し、それぞれの文脈で意味のある修正手順を提示します。汎用的な「接続文字列を確認してください」よりも、初見のエンジニアがパニックにならずに対処できます。
第4層:起動時バナーと攻撃面の縮小
開発サーバーの起動時に接続先を検査し、本番に繋がっている場合は赤いバナーで警告します。
┌─────────────────────────────────────────────┐
│ ⚠️ PRODUCTION DATABASE DETECTED │
│ Host: xxxx.supabase.co │
│ Role: postgres (WRITE ACCESS) │
│ Destructive operations are BLOCKED. │
└─────────────────────────────────────────────┘
「静かに止める」ガードだけでなく、「大声で叫ぶ」ガードを追加しました。ログの中に埋もれた警告より、チーム全員が見逃せない形で通知する方が有効です。
あわせて、使われなくなった移行スクリプト(3,000行超)と7つの npm スクリプトを削除しました。LLM がコンテキストを読んで「こんなコマンドがある」と誤って実行提案するリスクを根本から排除するためです。
第5層:日次バックアップ基盤の構築
Supabase Free プランでも最低限のバックアップ経路を確保するため、GitHub Actions による日次バックアップを構築しました。
- name: Dump and encrypt
run: |
pg_dump --format=custom --compress=9 \
--no-owner --no-acl \
--file "${DUMP_FILE}" "${BACKUP_DB_URL}"
age -r "${AGE_RECIPIENT}" -o "${ENC_FILE}" "${DUMP_FILE}"
shred -u "${DUMP_FILE}"
設計のポイントは4つです。
読み取り専用ロール: CI 用の backup_reader ロールは pg_read_all_data のみ。Secret が漏洩してもデータを破壊できません。
CREATE ROLE backup_reader LOGIN PASSWORD '<password>';
GRANT pg_read_all_data TO backup_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO backup_reader;
age による公開鍵暗号化: GPG より鍵管理がシンプルで、公開鍵1行で暗号化が完結します。復号には別途保管した秘密鍵が必要なため、CI に秘密鍵を置く必要がありません。
平文の即時消去: shred -u で暗号化前の平文ダンプを上書き削除します。rm だけではディスク上にブロックが残る可能性があります。
90日保持: GitHub Artifacts の retention-days: 90 で保持。現在の DB 規模(数 MB)では無料枠(500 MB)に十分収まります。
この設計で防げるシナリオ
| 攻撃パス | 防御層 |
|---|---|
.env.local に本番URLが混入 |
第1層(assertLocalDatabaseUrl) |
新しい関数から truncateUserState を直接呼ぶ |
第2層(関数自身のアサーション) |
| エラーが出たが修正方法がわからない | 第3層(文脈別エラーメッセージ) |
| 本番接続に気づかず開発を続ける | 第4層(起動時バナー) |
| 全防御を突破されてデータが消える | 第5層(日次バックアップから復元) |
つまづきやすいポイント
-
pg_dump のバージョン不一致: ubuntu-latest に同梱の
pg_dumpは旧バージョンで、Supabase の Postgres 17 系に接続するとエラーになります。PGDG リポジトリからpostgresql-client-17を明示的にインストールしてください -
--format=customの選択理由: plain text 形式と異なり、pg_restoreで特定テーブルだけの部分復元ができます。--no-owner --no-aclを付けることで Supabase 固有のロール依存を切り、別環境への restore もスムーズに通ります -
GitHub Artifacts の圧縮設定:
age出力はすでに高い圧縮率のため、artifact upload のcompression-level: 0を指定するとアップロード時間を節約できます -
復元訓練の実施: バックアップを「取れていること」と「復元できること」は別物です。四半期ごとの restore drill をランブックに組み込むことを推奨します
まとめ
E2Eテストのリセットスクリプトは、その性質上「データベースを空にする能力」を持っています。この能力が本番に向いた瞬間、プロジェクトは致命傷を負います。
今回のインシデントから得た最大の教訓は、「〜でなければスキップ」より「〜であることを確認してから実行」の方が安全ということです。否定形のガードはコメントと実装の乖離を生みやすく、レビューでも見落としやすい。positive assertion で入口を封鎖し、fail-fast するパターンを標準にしてください。
そして、バックアップのないデータベースに本番データを載せないでください。Supabase Free プランでも pg_dump + age + GitHub Actions で日次バックアップは30分で構築できます。

