Vercel CSPでreCAPTCHAスクリプトがブロックされていた話
ステータスは200。エラーログもない。なのにreCAPTCHAが動かない。
この状況、最初は完全に意味がわからなかった。「signup-verify のエンドポイントは正常に返ってるのに、なぜ?」と30分ほど頭を抱えた。答えはシンプルで、しかも「あ、そういうことか」と苦笑いするしかない見落としだった。
問題:「動いてる」と思っていた
PiloTube(パイロチューブ)の会員登録フローに reCAPTCHA v3 を導入した。スパム登録対策として必要な機能で、実装自体はそこまで複雑じゃない。バックエンドのエンドポイントを叩いてトークンを検証する部分は一通り書いて、ローカルで確認したら問題なく動いた。
「よし、本番に上げよう」
Vercel にデプロイして、ブラウザから signup フローを動かしてみる。エンドポイントは200を返している。バックエンドのログにもエラーはない。「完成だ」と思ってブラウザのコンソールを閉じようとしたとき、赤いエラーが目に入った。
Refused to load the script 'https://www.google.com/recaptcha/api.js'
because it violates the following Content Security Policy directive:
"script-src 'self' ..."
reCAPTCHAのスクリプト自体が読み込まれていなかった。
気づき:CSPが全部止めていた
Content Security Policy、略してCSP。「どのオリジン(ドメイン)からのリソースを許可するか」をHTTPヘッダーで制御する仕組みだ。XSS(クロスサイトスクリプティング)対策として非常に有効で、PiloTubeの本番環境では Vercel の設定でCSPを有効にしていた。
問題は、script-src の許可リストに www.google.com を追加していなかったこと。
reCAPTCHA v3 は https://www.google.com/recaptcha/api.js を外部スクリプトとして読み込む。CSPが 'self'(自分のドメインのみ)しか許可していなければ、当然ブロックされる。
ローカル開発環境ではCSPを厳密に設定していなかったので、この問題は出なかった。本番にデプロイして初めて発覚する類の罠だった。
課題:「なぜ200が返ってくるのか」で混乱した
正直、最初に混乱したのはここだった。
「signup-verify のエンドポイントが200を返している」という事実が、問題の特定を遅らせた。バックエンドは正常に動いている。なのにreCAPTCHAが機能しない。「バックエンドの実装に何か問題があるのか?」「トークンの渡し方が間違っているのか?」と、엉뚱한方向で考え始めていた。
実際の流れを整理すると、こうなる。
- フロントエンドが
www.google.com/recaptcha/api.jsを読み込もうとする - CSPがブロックする → スクリプトが読み込まれない
- reCAPTCHAトークンが生成されない
- トークンなしでフォームが送信される
- バックエンドは「トークンなし」の状態を受け取って処理する → 200を返す
つまりバックエンドは「reCAPTCHAなし」の状態でリクエストを処理していただけで、reCAPTCHAの検証自体が走っていなかった。200が返ってきていたのは「正常動作」ではなく「reCAPTCHAをスルーした状態」だった。
これは普通にまずい。スパム登録が素通りできる状態だった。
実践:Vercel の vercel.json を修正する
修正自体は5分で終わった。vercel.json の CSP ヘッダー設定に www.google.com を追加するだけだ。
修正前の script-src はこんな感じだった(簡略化):
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "script-src 'self' 'nonce-xxx';"
}
]
}
]
}
これを以下のように修正した:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "script-src 'self' 'nonce-xxx' https://www.google.com https://www.gstatic.com;"
}
]
}
]
}
www.google.com だけでなく www.gstatic.com も追加している。reCAPTCHA v3 は内部的に gstatic.com からもリソースを読み込むため、これも許可しないと別のCSP違反が発生する。最初は www.google.com だけ追加してデプロイしたら、今度は gstatic.com のエラーが出た。2回デプロイする羽目になった。
結果:ブラウザコンソールがきれいになった
修正後にデプロイして、ブラウザのコンソールを開きながら signup フローを動かした。
赤いエラーが消えた。reCAPTCHAのバッジ(画面右下に出る小さなGoogle表記)も表示されるようになった。トークンが正常に生成されて、バックエンドでの検証も通るようになった。
「ようやくちゃんと動いた」という感覚があった。200が返ってくることと「正しく動いている」ことは、別の話だと改めて思い知らされた瞬間だった。
教訓:本番テストは「コンソールを開いた状態で」やる
この件で学んだことをまとめる。
1. ステータスコードだけ見ていると見逃す
200が返ってきていても、フロントエンドで何かがブロックされていれば「正しく動いている」とは言えない。本番テストのときはブラウザの開発者ツール(コンソール・ネットワークタブ)を必ず開いた状態で確認する習慣をつける。
2. 外部スクリプトを使うときはCSPを先に確認する
reCAPTCHA、Stripe、Google Analytics、Intercom……外部サービスのスクリプトを組み込むときは、必ずそのサービスが使うドメインをCSPに追加する必要がある。各サービスのドキュメントに「CSPの設定」セクションがあることが多いので、実装前に確認するのが正解だった。
reCAPTCHA v3 の場合、必要なドメインはこの2つ:
https://www.google.comhttps://www.gstatic.com
3. ローカルと本番のCSP設定を揃えておく
開発環境でCSPを緩くしていると、本番デプロイ後に初めて問題が発覚する。できれば開発環境でも本番に近いCSP設定で動かしておくか、少なくとも「本番には厳しいCSPがある」と意識しながら外部リソースを追加する。
4. 「動いてるっぽい」で終わらせない
これが一番の反省だ。ローカルで動いたことを確認して「完成」と判断してしまった。本番環境で一通りのフローをコンソール付きで確認するステップを省いてしまったのが、この問題を見逃した根本的な原因だった。
CSPは「設定しておくと安心」なセキュリティ機能だけど、外部サービスを追加するたびに設定の更新が必要になる。地味で見落としやすい作業だが、こういうところで穴が開く。PiloTubeはまだ開発中の機能が多いので、外部サービスを組み込むたびにCSPの確認をルーティン化していくつもりだ。
チャプター生成AI
URL貼るだけ。AIがチャプターを自動生成。
YouTubeのURLをコピーして貼る
「生成する」を押す
概要欄にコピペして完了
月3回まで無料 · クレジットカード不要