Back to posts

Cloudflare Turnstileでフォームのスパム対策

このサイトはAstroで作った静的ブログで、ページは全部ビルド時に生成されるHTMLです。手軽な反面、「訪問者から何かを受け取る」仕組みは持っていません。問い合わせフォームのような導線を足そうとすると、送信先の用意から考える必要があります。

そして、フォームを公開した瞬間にやって来るのは人だけではありません。自動送信のスパムbotは、公開直後から送信先を探しに来ます。静的サイトでは受け口自体が自作になるので、スパム対策は「後から足すもの」ではなく、設計のうちに入れておく話になります。このサイトにはまだフォーム自体が無いのですが、足すときの第一候補として Cloudflare Turnstile を調べたので、検討メモとして残しておきます。

Turnstileとは何か。CAPTCHAとの違いから

Turnstileは、Cloudflareが提供する「CAPTCHAの代替」をうたう仕組みです。ページに小さなウィジェットを埋め込むと、ブラウザ側でいくつかの小さいJavaScriptチャレンジ(proof-of-workと呼ばれる計算課題や、Web APIの探り、ブラウザ環境の癖の検出など)が走り、その結果としてトークンが発行されます。そのトークンをサーバー側で検証して、人からの送信かどうかを判断します。

いわゆる従来型のCAPTCHAとの違いを、ざっくり並べるとこうなります。

観点従来型CAPTCHA(画像判別など)Turnstile
訪問者に求めること画像を選ぶ、ゆがんだ文字を読み取る原則として操作なし。怪しい場合でもチェックボックスを押すまでで、画像や文字の判読は出ない
判定のしかた課題を解けたかどうかブラウザ環境のシグナルや小さいJSチャレンジで、訪問者ごとに難易度を変えて判定
扱うデータサービスによる公式ドキュメントに「セキュリティ機能に厳密に必要なデータだけを処理し、フォームの入力内容にはアクセス・保存・送信しない」と明記されている
導入の前提サービスごとに異なるサイトがCloudflareを経由していなくても、任意のサイトに埋め込める

個人的に一番大きいと感じたのは、1行目です。問い合わせフォームはただでさえ「書くのが面倒」な導線で、そこに画像判別が乗ると、面倒さで離脱する人が出ます。TurnstileのManagedモードでは、判断に迷う訪問者にだけチェックボックスが出ます。そのチェックボックス自体も、画像を選ばせるような課題にはなりません。

アクセシビリティの面でも、公式ドキュメントでWCAG 2.2 AAへの準拠がうたわれています。画像判別は、見え方や操作が苦手な人にとって詰みやすい箇所なので、この差は小さい話ではありません。

仕組みは「ウィジェット+サーバー検証」の2段構え

Turnstileでは、キー(鍵)が2種類あるところが肝心です。

  • サイトキー: ページのHTMLに書く公開用のキー。ウィジェットを呼び出すためのもので、見られても問題ない前提の値です
  • シークレットキー: サーバー側だけが持つ非公開のキー。トークンの検証に使います。クライアント側のコードに書いてはいけません

導入の流れはこうなります。

  1. CloudflareのダッシュボードでTurnstileのウィジェットを作り、サイトキーとシークレットキーを取得する(ウィジェットごとに1ペア発行される)
  2. フォームのページにTurnstileのスクリプトとウィジェットを埋め込む
  3. 訪問者のブラウザでチャレンジが走り、通るとトークンが発行される
  4. フォーム送信時、トークンが cf-turnstile-response という名前で一緒に送られる
  5. サーバーが、トークンとシークレットキーをCloudflareのSiteverify APIに送って検証する
  6. 応答の success がtrueのときだけ、メール送信などの本処理に進む

クライアント側の埋め込みはこれだけです。

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form action="/api/contact" method="POST">
  <!-- 名前・メールアドレス・本文などの入力欄 -->
  <div class="cf-turnstile" data-sitekey="あなたのサイトキー"></div>
  <button type="submit">送信する</button>
</form>

cf-turnstile クラスのdivを置くと、チャレンジ成功時に隠しフィールド cf-turnstile-response が自動で作られ、トークンがフォームと一緒に送信されます。なお、ウィジェットは challenges.cloudflare.com から読み込まれるので、サイト側でContent Security Policyを設定している場合は、このドメインへの接続を許可しておく必要があります。

ポイント: サーバー側の検証までが1セット ウィジェットを貼っただけでは、対策にはなりません。botはブラウザを経由せず、送信先のURLへ直接POSTできます。その場合ウィジェットは目に入らないので、トークンも発行されません。公式ドキュメントでも「サーバー側の検証は必須」とされていて、Siteverifyを呼ばない構成は未完成だと明言されています。貼って終わりにしないことが、この仕組みのいちばんの要点だと思います。

静的サイトでは「検証する側」をどこに置くか

ここが静的ブログならではの話です。静的ホスティングはHTMLを配るだけで、POSTは受け取れません。つまりTurnstile以前に、「送信を受けて、検証して、メールを送る」役回りをどこかに用意する必要があります。

このサイトはCloudflare Pagesで配信する構成なので、同じプロジェクトに置けるPages Functionsが自然な置き場になります。Astroのビルド成果物とは別に、/api/contact のようなパスへ関数を1つ置く形です。Cloudflareに寄せていない構成なら、小さいWorkerを1つ立てる、フォームの受け口だけ外部サービスにする、といった選択肢もあります。なお、「Pagesに置くか、Workerとして独立させるか」の線引き自体は、Cloudflare WorkersとPagesの使い分け:静的で済むか、リクエスト時に処理が要るかで整理しました。

検証処理そのものは、Siteverify APIへのPOSTが中心で、思ったより短く書けます。Pages Functionsを想定した例だとこんな形です。

// functions/api/contact.js(検証部分のみの抜粋)
export async function onRequestPost({ request, env }) {
  const form = await request.formData();
  const token = form.get("cf-turnstile-response");

  const body = new FormData();
  body.append("secret", env.TURNSTILE_SECRET_KEY);
  body.append("response", token);
  body.append("remoteip", request.headers.get("CF-Connecting-IP") ?? "");

  const res = await fetch(
    "https://challenges.cloudflare.com/turnstile/v0/siteverify",
    { method: "POST", body },
  );
  const outcome = await res.json();

  if (!outcome.success || outcome.hostname !== "pastora.jp") {
    return new Response("verification failed", { status: 400 });
  }

  // ここで初めて、メール送信などの本処理に進む
}

secret と response が必須パラメータで、応答はJSONで返ります。success のほかに hostname(チャレンジが実行されたホスト名)や action(ウィジェットに付けられる任意の識別子)も含まれていて、公式のベストプラクティスでは、これらが想定どおりか追加で照合することも勧められています。別サイトで取得したトークンの使い回しを防ぐ意味で、hostnameの確認は入れておくつもりです。

シークレットキーはコードに直書きせず、環境変数(上の例では env.TURNSTILE_SECRET_KEY)から読むのが前提です。サイトキーは公開情報、シークレットキーは秘密情報。この線引きを間違えると、検証の意味が無くなります。Pages/Workersで平文の環境変数とSecretをどう置き分けるかは、Cloudflare Pages/Workersの環境変数と秘密情報の扱いにまとめています。

注意: トークンには寿命と使用回数の制限がある トークンの有効期間は生成から300秒(5分)で、検証できるのは1回だけです。問い合わせフォームは長文を書く人もいるので、ページを開いてから送信まで5分を超えると、期限切れで timeout-or-duplicate というエラーコードが返ります。失敗したときにウィジェットをリセットして新しいトークンを取り直す導線(turnstile.reset())まで含めて設計しておかないと、正規の訪問者を締め出しかねません。

ウィジェットモードは3種類。まずはManagedでいい

ウィジェットを作るときにモードを選びます。違いは「訪問者からどう見えるか」で、裏側のチャレンジと検証の仕組みは共通です。

モード見え方訪問者の操作
Managed(推奨)リスクが高いと判断されたときだけ、チェックボックスが出る通常はなし。必要な場合のみチェックボックスを押す
Non-Interactiveスピナー付きのウィジェットが表示される一切求められない
Invisible何も表示されない一切求められない

どれにするかは、フォームの性格で決めればいいと思っています。問い合わせフォームは「確実に届いてほしい」導線なので、判定が際どいときにチェックボックスへ逃がせるManagedが無難です。Non-InteractiveとInvisibleは操作ゼロを貫けますが、チャレンジに失敗した訪問者の受け皿がありません。見た目を完全に消したい場面ならInvisible、という使い分けが、公式ドキュメントの想定にも近い整理です。

テストキーと本番キーは、環境ごとに分ける

Turnstileには公式のダミーキー(テストキー)が用意されていて、これが地味に便利です。キーごとに結果が固定されているので、成功と失敗の両方の経路を動作確認で踏めます。

確認したいことサイトキーシークレットキー
常に成功する1x00000000000000000000AA1x0000000000000000000000000000000AA
常に失敗する2x00000000000000000000AB2x0000000000000000000000000000000AA
期限切れ・二重検証のエラーになる成功系のサイトキーでよい3x0000000000000000000000000000000AA

テストキーは localhost を含む任意のドメインで使えます。逆に、本番のシークレットキーはダミートークン(XXXX.DUMMY.TOKEN.XXXX)を弾き、テスト用のシークレットキーは本物のトークンを弾くので、環境ごとにペアで差し替える運用になります。サイトキーは通常の環境変数、シークレットキーは秘密情報用の環境変数に入れて、コード側はキーの値を直接持たない形にしておくのが安全です。

怖いのは、本番にテストキーが残ったまま公開される事故です。「常に成功する」キーが残っていれば、botも常に成功します。公開前のチェックに入れておきます。

  • 本番環境のサイトキー・シークレットキーが、テストキーのままになっていないか
  • シークレットキーがクライアント側のコードやリポジトリに入っていないか
  • トークンなし・期限切れトークンで送信して、ちゃんと弾かれるか
  • ウィジェットの許可ホスト名に、不要なドメイン(localhostなど)が残っていないか

このサイトでの結論

整理すると、Turnstileは「フォームの入口に立つ門番」であって、フォームの受け口そのものではありません。静的サイトに問い合わせ導線を足すなら、本体はPages Functionsで作る送信基盤のほうで、Turnstileはその入口に付ける部品、という順番で考えると全体像を間違えにくいです。

このサイトで実際に足すときは、Managedモードのウィジェットと、Pages FunctionsでのSiteverify検証という最小構成で始める想定です。モードの変更や照合項目の追加といった調整の余地は後からでも残せるので、最初から作り込みすぎないほうが、個人ブログの規模には合っていると思っています。

参考