お問い合わせフォームのスパムを止める

お問い合わせフォームのスパムを止める2 分で読めます

HTML フォームに Cloudflare Turnstile を追加する方法

プレーンな HTML フォームに Cloudflare Turnstile を追加します。ウィジェット、サーバー側の siteverify、テスト用キー、自前のキーやサーバーなしで動かす方法まで。

Preet SutharIkiform 創業者

Turnstile のスクリプトと cf-turnstile 要素をフォームの中に追加すると、ウィジェットが非表示の cf-turnstile-response フィールドにトークンを入れます。そのトークンとシークレットキーをサーバーから Cloudflare の siteverify エンドポイントに送り、success が返ったときだけ送信を受け付けます。サーバーがなければフォームバックエンドに検証させるか、キーがまったく要らない Ikiform のマネージドチェックを使います。

Turnstile ならではの違い

Turnstile は 2 つの部分からなりますが、プレーンな HTML フォームには 1 つ分の場所しかありません。ウィジェットは公開の site key を使ってブラウザーで動き、トークンを生成します。チェックは secret key を使ってサーバーで動き、このキーは決してページに出してはいけません。ウィジェットを表示するだけでトークンを検証しないフォームは飾りです。ボットはエンドポイントに直接送信し、ウィジェットを完全に飛ばします。

つまり HTML フォームで本当に問題になるのは、シークレットをどこに置くかです。このページでは 3 つの答えを扱います。自前の小さなサーバー、キーを預かるフォームバックエンド、そしてバックエンド以外は誰もキーを持たないマネージドチェックです。

Turnstile トークンの一生: ウィジェットから判定までブラウザーで、サイトキーを使ってサーバーで、シークレットキーを使ってウィジェットが動くたいていは見えないトークンを発行hidden のcf-turnstile-response入力欄フォームを送信トークンがフィールドと一緒に届くsiteverifyシークレット + トークン + 訪問者のIP応答を確認success、hostname、action送信を受け付ける拒否する送信すべて一致どれかが違うTurnstile トークンの一生: ウィジェットから判定までブラウザーで、サイトキーを使ってサーバーで、シークレットキーを使ってウィジェットが動くたいていは見えないトークンを発行hidden のcf-turnstile-response入力欄フォームを送信トークンがフィールドと一緒に届くsiteverifyシークレット + トークン +訪問者の IP応答を確認success、hostname、action送信を受け付ける拒否する送信すべて一致どれかが違う
  1. Turnstile のウィジェットは、公開の site key を使ってブラウザーで動きます。ほとんどの訪問者は、それが何かしている様子を目にしません。
  2. トークンを発行し、フォームの中の非表示の cf-turnstile-response 入力欄に入れます。
  3. フォームが送信され、トークンも他のフィールドと一緒に送られます。
  4. サーバーが、シークレットキー、トークン、訪問者の IP を Cloudflare の siteverify エンドポイントに送ります。
  5. 応答が success であること、ホスト名と action が想定どおりであることを確認します。
  6. すべて一致すれば送信を受け付けます。どこかがおかしければ拒否します。
トークンは 300 秒有効で、検証できるのは 1 回だけです。同じトークンを 2 回確認すると timeout-or-duplicate で失敗します。

ウィジェットを追加する

Cloudflare のダッシュボードの Turnstile でウィジェットを作成し、フォームを置くホスト名を登録して、サイトキーをコピーします。次に、スクリプトと cf-turnstile 要素をフォームの中に追加します。要素は必ず <form> の中に置いてください。外にあると hidden のトークンフィールドもフォームの外にでき、送信されません。

contact.html
<form action="https://example.com/api/contact" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
  • data-action="contact" はウィジェットのラベルです。siteverify がこれを返すので、サーバーは別のフォーム用に発行されたトークンを拒否できます。
  • スクリプトは Cloudflare 自身の URL から読み込む必要があります。Cloudflare によると、プロキシしたコピーやセルフホストしたコピーではウィジェットが動きません。
  • サイトが Content Security Policy を送っている場合は、script-src と frame-src の両方で https://challenges.cloudflare.com を許可してください。
  • Cloudflare は無料でウィジェットを 20 個まで、それぞれホスト名 10 個まで使えるので、ステージング環境のホストも登録しておきましょう。

自分のサーバーでトークンを検証する

トークンはどんなサーバーでも検証できます。シークレットとトークンを付けて siteverify に POST を 1 回送るだけです。ここでは、静的なフォームの送信先になる Cloudflare Worker として実装しています。トークン、ホスト名、action を確認し、トークンを取り除いてから、シークレットキーでフィールドを Ikiform に転送します。転送先は、メッセージを保存したりメールしたりする別の仕組みに替えても構いません。

src/index.ts
interface Env {
  TURNSTILE_SECRET_KEY: string;
  IKIFORM_SECRET_KEY: string;
}

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

const SITE = "https://example.com";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
    const form = await request.formData();

    const check = await fetch(SITEVERIFY, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        secret: env.TURNSTILE_SECRET_KEY,
        response: String(form.get("cf-turnstile-response") ?? ""),
        remoteip: request.headers.get("CF-Connecting-IP"),
        idempotency_key: crypto.randomUUID(),
      }),
    });
    const verdict = (await check.json()) as Verdict;
    const expected = verdict.hostname === new URL(SITE).hostname && verdict.action === "contact";
    if (!verdict.success || !expected) {
      console.warn("turnstile", verdict["error-codes"] ?? [], verdict.hostname, verdict.action);
      return Response.redirect(`${SITE}/contact?error=check`, 303);
    }

    form.delete("cf-turnstile-response");
    const sent = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${env.IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("Origin") ?? SITE,
      },
      body: form,
    });
    return Response.redirect(sent.ok ? `${SITE}/thanks` : `${SITE}/contact?error=send`, 303);
  },
};
  • siteverify は JSON とフォームエンコードのどちらのボディも受け付け、常に JSON で応答します。remoteip は任意です。idempotency_key を使うと、ネットワークエラーの後でも安全に確認をリトライできます。
  • success だけでなく hostname と action も確認してください。あなたのサイトキーを使って他人のページで発行されたトークンでも success になります。
  • 2 つのシークレットはどちらも wrangler secret put で保存し、wrangler.toml には決して書かないでください。
  • Worker は 303 リダイレクトで応答するので、プレーンなフォームに JavaScript は要りません。フォームの action を Worker のルートに向けます。
  • ページの Origin ヘッダーを引き継いで渡します。サーバー側の fetch はこれを送らず、Ikiform は無料の localhost テストの判定にこれを使うからです。

確認に失敗したときにフォームのページでエラーを表示するには、Worker が付けるクエリ文字列を読みます:

contact.html
<p id="form-error" role="alert" hidden></p>
<script>
  const reason = new URLSearchParams(location.search).get("error");
  const banner = document.getElementById("form-error");
  if (reason && banner) {
    banner.textContent =
      reason === "check" ? "The human check didn't pass. Try again." : "Your message didn't send. Try again.";
    banner.hidden = false;
  }
</script>
サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains と自身の人間確認をスキップします。すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分 5 件という Standard の制限は全訪問者の合計で数えられます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

Ikiform に検証を任せる

サーバーをまったく運用したくないなら、フォームから直接 Ikiform に送信し、siteverify の呼び出しを Ikiform に任せられます。方法はフォームごとの Spam check 設定で決まります。

方法自分で管理するキーページ上のウィジェット自前のサーバー
上で作った自分のサーバーまたは Workerサイトキーとシークレットキーあり(自分のもの)はい
Ikiform で Your own Turnstile keysサイトキーとシークレットキー(Ikiform に貼り付け)あり(自分のもの)なし
Ikiform で Ikiformなしなし。ikiform.com の確認ページか小さなフレームなし

Your own Turnstile keys では、サイトキーとシークレットキーをフォームの設定に貼り付けます。シークレットは暗号化して保存されます。ウィジェットのマークアップはそのままで、フォームの送信先を Ikiform のエンドポイントにします。Ikiform が cf-turnstile-response を検証し、トークンのホスト名をフォームの Allowed domains(許可ドメイン)と照合します。有効なトークンがない送信は、シークレット API キーがあっても 403 captcha_failed になります。

contact.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

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

Ikiform では、ページに何も追加しません。トークンなしのプレーンな HTML 送信には ikiform.com の短い Checking you're human ページが表示され、そこで Turnstile を実行してからフィールドを送ります。fetch による送信には代わりに 403 challenge_required が返るので、先に @ikiform/sdk の challenge() を呼んでトークンを取得してください。Cloudflare のアカウントは不要です。両方の流れはスパムのドキュメントにあります。

Cloudflare に接続できない場合でも、Ikiform は送信を捨てません。理由 captcha_unavailable を付けてスパムとして保存するので、ダッシュボードから復元できます。自分のサーバーでも同じ判断を意図して行うべきです。上の Worker のように拒否する側に倒すか、確認用に送信を残すかです。

テストキー

Cloudflare は、localhost を含むどのホスト名でも使えるダミーのキーを公開しています。開発中はそれを使い、後で本物のキーに差し替えてください。

キー値動作
サイトキー1x00000000000000000000AA常に通過、ウィジェットは表示
シークレットキー1x0000000000000000000000000000000AAsiteverify が常に成功を返す
シークレットキー2x0000000000000000000000000000000AAsiteverify が常に失敗を返す

テスト用のトークンには本物のホスト名が入っていないので、Worker のホスト名チェックで拒否されます。このチェックを緩めるのは開発環境だけにし、本番では決して緩めないでください。

動作を確認する

  1. 普通にフォームを送信します。送信は受け付けられ、ウィジェットは何も表示しないか、チェックボックスを 1 つ表示するだけです。
  2. cf-turnstile 要素を削除して送信します。送信は拒否されます。Worker が ?error=check 付きでリダイレクトするか、Ikiform が 403 captcha_failed を返します。
  3. 送信してから戻るボタンを押し、同じページでもう一度送信します。再利用したトークンは失敗するので、サーバーが本当に検証していることがわかります。
  4. ダッシュボードで Submissions を開きます。送ったすべてのフィールドを持つ新しい送信データがあります。
  5. 何も届きませんか?ダッシュボードのステータスフィルターを Spam に切り替えてください。スパムにも本物の送信データと同じ成功の応答が返ります。

ハマりどころ

  • トークン 1 つにつき 1 回。 fetch で処理した送信が失敗したら、turnstile.reset() を呼んで新しいトークンを取得します。ページ全体を再読み込みすれば自動で取得されます。
  • 入力に時間がかかる人。 トークンは 300 秒で期限切れになります。ウィジェットの data-refresh-expired 設定で、自動で新しいトークンを取るか、クリックを待つかが決まります。
  • www とルートドメイン。 siteverify にとって www.example.com と example.com は別のホスト名です。フォームが実際に動いている方と比較するか、両方を許可してください。
  • シングルページのナビゲーション。 暗黙的なレンダリングのスクリプトは、読み込み時に 1 回だけウィジェットを描画します。クライアント側でルートが変わる場合は、?render=explicit と turnstile.render() で明示的に描画してください。
  • Ikiform 自身のキーを使ったサーバーからの送信。 サーバーがシークレットキーで Ikiform に転送すると、Ikiform はマネージドの確認をスキップします。Worker と同じように、先に自分でトークンを検証してください。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com