きっかけ
MenkyoKit は在日外国人向けの運転免許試験学習サイトで、Next.js 14 + Supabase + Stripe、コードの大半は AI エージェントがコマンドラインで書いている。ある日こんな動画を見た。AI で作ったサイトが公開数日で、アカウントを乗っ取れる鍵 150 万件、メールアドレス 3 万件超、DM 4,000 件を丸ごと抜かれた。相手はツールを何も使わず、右クリックでソースを見ただけだった。
動画は vibe coding 時代の典型的な落とし穴を、コードを書かずにできる 5 つのチェックにまとめていた。その日のうちに自分たちのサイトを 5 項目すべて通してみた。以下がその過程と結果である。
5つのチェック
| チェック | 実測方法 |
|---|---|
| 1 · DBはログインなしで読めるか | フロントに公開している Supabase キーで REST を直接叩き、テーブルごとに読み・書きを試す |
| 2 · ソースに鍵が入っていないか | トップページが参照する JS バンドル全 13 本(約 840 KB)を取得し、sk_live、whsec_、service_role、価格 ID を grep |
| 3 · 延々と叩き続けられるか | 全 API ルートを読み、レート制限のコードを探す |
| 4 · URL の数字を +1 したら | ID でデータを引くパスと API を洗い出し、「ログイン済み」だけ見て「本人か」を見ていない箇所がないか |
| 5 · シークレットウィンドウで直接入る | Cookie なしで管理画面・開発ツール・アカウント・有料ページ・各 API にアクセス |
結果
チェック1 · 合格
8 テーブルすべてで行レベルセキュリティ(RLS)が有効。匿名キーで purchases、entitlements、user_progress、mock_attempts、page_views、route_geometry、redeem_codes、admins を読むと全部空配列。匿名の書き込みは DB 側で拒否(エラーコード 42501)。ログインユーザーの読み書きポリシーはすべて auth.uid() = user_id で、自分の行しか触れない。引換コード表・統計表・ルート形状表はポリシー自体を持たず、サーバー側の鍵でしか触れない。
チェック2 · 合格
JS バンドルに入っているのは Supabase の publishable キーだけ。これはブラウザ向けに設計されたもので、できることは RLS が決める。Stripe の秘密鍵、Webhook 署名鍵、サーバー側の鍵、価格 ID はどれも含まれていない。
小さな傷が一つ。バンドルに SUPABASE_SERVICE_ROLE_KEY という変数名が出てくる。Next.js は NEXT_PUBLIC_ で始まらない環境変数をフロントに埋め込まないので値は undefined、漏洩ではない。ただ、サーバー側の鍵を読むモジュールがブラウザ側コードから参照されている印なので、分離した方がよい。
チェック3 · 穴あり
コードにレート制限が一切なかった。公開 API 3 本はすべて無制限に叩ける。アクセス統計のビーコンはスクリプトで無限に書き込める。引換コードと Stripe Checkout セッション作成はログイン必須だが、ログイン後の回数制限がない。ログイン・登録は Supabase Auth 組み込みのレート制限に任せており、こちらの API を通らない。
チェック4 · 合格
ユーザー ID や注文番号でデータを引く URL はサイト全体に存在しない。アカウントページはサーバー側でログイン中のユーザーだけを照会し、DB 層でも RLS が受け止める。管理画面の権限付与・コード発行・削除は呼び出しごとに管理者テーブルを引き直す。ルート編集 API は ID の形式を検証し管理者のみ。決済完了ページはセッション ID を持たない。
チェック5 · 合格
ログインなしでアクセスすると、管理画面 3 ページとルート編集 API は 404(403 ではなく、存在を明かさない)。開発ツールのページと API は本番で 404。アカウントページはログインへリダイレクト。Checkout と引換 API は 401。Stripe Webhook は署名なしで 400。有料コースと 50 問模擬試験のページはサーバー側でペイウォールを返し、HTML にも RSC ペイロードにも本文と問題は含まれない。
レート制限をどう入れたか
このために外部サービスを増やしたくないし、レート制限器が落ちたら決済が止まる、という構造にもしたくない。結局 2 層にした。
メモリ層:Vercel の各インスタンスが自前の Map を持ち、IP ごとに直近 1 分間のタイムスタンプを記録する。コストゼロ・遅延ゼロだが、インスタンス間で共有されないので、洪水がインスタンス数で割られるだけ。アクセス統計ビーコンに使い、超過分は黙って捨てて 204 を返す。スクリプト側からは制限されたことが分からない。
DB 層:Supabase に rate_limits テーブルと security definer 関数を作り、1 回の upsert で「窓が切れていればリセット、そうでなければ +1」して超過判定を返す。関数は service_role にだけ実行権限を与え、匿名・ログインユーザーの呼び出しは拒否される。引換コードと Checkout に使い、全インスタンスで件数を共有する。
insert into rate_limits (key, count, window_start)
values (p_key, 1, now())
on conflict (key) do update set
count = case when rate_limits.window_start < v_cutoff then 1
else rate_limits.count + 1 end,
window_start = case when rate_limits.window_start < v_cutoff then now()
else rate_limits.window_start end
returning count into v_count;
return v_count <= p_limit;
しきい値:統計ビーコンは IP ごとに毎分 30 回。引換コードはユーザーごとに 10 分で 10 回、IP ごとに 30 回。Checkout はユーザーごとに 10 分で 6 回。関数が存在しない、または DB に届かないときは通す。レート制限器の障害を決済障害にしてはいけない。
公開後に本番 DB で実測:同じキー・上限 2 で 3 回呼ぶと true、true、false。匿名キーで関数を呼ぶと permission denied。匿名でテーブルを読むと空。
一つの数字
動画を見終えてから本番検証が通るまで、チェックと修正で約 2 時間。チェック自体はほぼコードを書かない。curl 一本、grep 一本、シークレットウィンドウ一枚。
同じように AI でサイトを作る人へ
- RLS を有効にするのは入口に過ぎない。匿名キーで全テーブルを本当に読んでみること。
- フロントの JS バンドルを落として grep する。HTML だけ見て済ませない。
- 「ログイン必須」は「制限あり」ではない。ログイン後の API にもレート制限を。
- 保護ページはサーバー側で止める。中身をページに入れてから CSS で隠すのは無意味。
- レート制限器は fail-open に設計する。さもないと最も脆い単一障害点になる。
出典動画:小袋鼠KAKO『AI 做完的网站,直接上线安全吗?上线前你必须做的 5 个检查』。