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

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

Turnstile を使った React Router フォームのスパム対策

React Router のフォームをスパムから守る方法。Turnstile 付きのルート Action、cf-connecting-ip をキーにした Workers のレート制限、.server モジュール、CSP ヘッダー。

Preet SutharIkiform 創業者

チェックはルートの action で行います。クライアント IP で制限し、siteverify で Turnstile のトークンを検証してから、ハニーポットと一緒にフィールドを転送します。ヘルパーは .server.ts モジュールに置いてブラウザーに届かないようにし、CSP はルートの headers エクスポートで設定します。Cloudflare Workers では、レート制限バインディングとシークレットは cloudflare:workers の env から取得します。

React Router ならではの違い

フレームワークモードでは、action を持つすべてのルートが、自分のフォームからでも他人からでも POST を受け付けます。React Router には、スパムチェックの組み込み方を左右する 3 つの道具があります:

  • .server モジュール。 spam.server.ts という名前のファイルはクライアントのコードにバンドルできないので、コンポーネントが同じルートからインポートしても、シークレットと siteverify の呼び出しはサーバーにとどまります。
  • headers エクスポート。 Action が返すヘッダーは、ルートの headers 関数が引き渡さない限り、ドキュメントのレスポンスでは送られません。CSP もここに置きます。
  • useFetcher。 ナビゲーションなしで Action に送信するので、ページとウィジェットは試行の間もマウントされたままです。そのため、応答のたびに key でウィジェットをマウントし直します。

このページでは、レート制限バインディングが組み込まれていて React Router のデプロイ先としても一般的な Cloudflare Workers にデプロイします。Vercel と Netlify についてはデプロイの表で説明します。

JavaScript の有無で、送信が React Router の Action に届くまでハイドレーション後: useFetcherハイドレーション前: ドキュメントの POSTfetcher.Form遷移なしaction制限、siteverify、転送fetcher.dataエラーかお礼新しいウィジェットkey が変わる通常のフォーム送信ウィジェットは描画されないactionsiteverifyが拒否headersエクスポートCSP、Retry-AfteractionDataエラーを表示POSTdata()再マウントPOST描画JavaScript の有無で、送信が React Router の Action に届くまでハイドレーション後: useFetcherハイドレーション前: ドキュメントの POSTfetcher.Form遷移なしaction制限、siteverify、転送fetcher.dataエラーかお礼新しいウィジェットkey が変わる通常のフォーム送信ウィジェットは描画されないactionsiteverify が拒否headers エクスポートCSP、Retry-AfteractionDataエラーを表示POSTdata()再マウントPOST描画
  1. ハイドレーション後は、フォームが useFetcher で送信するので、ページは遷移しません。
  2. Action が IP でレート制限し、Turnstile のトークンを検証して、フィールドを転送します。
  3. 結果は fetcher.data として届きます。
  4. ウィジェットの key が変わるので、次の試行に向けて新しいウィジェットと新しいトークンが用意されます。
  5. ハイドレーション前は、同じフォームが Turnstile のトークンなしの通常のドキュメントリクエストとして送信されます。
  6. Action は siteverify の段階でそれを拒否します。
  7. headers エクスポートが、Retry-After と CSP をドキュメントのレスポンスに引き渡します。
  8. コンポーネントは fetcher.data の代わりに actionData を読み、エラーを表示します。
人間確認には JavaScript が必要なので、JavaScript なしの経路は黙って通すのではなく、わかりやすいエラーで終わります。

プロジェクトの準備

@cloudflare/vite-plugin を使う Cloudflare の React Router テンプレートから始めます。React Router 8 では context 引数は常に RouterContextProvider で、Worker のエントリーから素の { cloudflare: { env } } オブジェクトを渡すと例外が発生します。そのため、エントリーはリクエストだけを渡し、ルートは import { env } from "cloudflare:workers" でバインディングを読みます。このページで編集するファイル:

ファイル
app/
  routes/
    contact.tsx
  components/
    turnstile.tsx
  lib/
    spam.server.ts
workers/
  app.ts
wrangler.jsonc
.dev.vars
workers/app.ts
import { createRequestHandler } from "react-router";

const handler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);

export default {
  fetch(request) {
    return handler(request);
  },
} satisfies ExportedHandler<Env>;
wrangler.jsonc
{
  "name": "acme-site",
  "main": "./workers/app.ts",
  "vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
  "ratelimits": [
    {
      "name": "CONTACT_LIMIT",
      "namespace_id": "1001",
      "simple": { "limit": 5, "period": 60 }
    }
  ]
}
.dev.vars
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
  • サイトキーは公開情報なので vars に置きます。ローダーがコンポーネントに渡すので、クライアントのビルドに環境変数は一切要りません。
  • シークレットはローカルでは .dev.vars に、本番では wrangler secret put で設定し、決して wrangler.jsonc には書かないでください。
  • 設定を編集したら wrangler types を実行して、env 上の CONTACT_LIMIT とシークレットに型が付くようにします。

サーバー専用のヘルパー

app/lib/spam.server.ts
export async function isHuman(secret: string, token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  const body = new URLSearchParams({ secret, response: token, remoteip: ip });
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (import.meta.env.DEV || verdict.hostname === host);
  } catch {
    return false;
  }
}

export async function forward(secretKey: string, form: FormData, origin: string | null) {
  const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${secretKey}` });
  if (origin) headers.set("Origin", origin);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", { method: "POST", headers, body: form }).catch(() => null);
  return response?.ok ?? false;
}
  • .server の接尾辞により、クライアントのコードがこのモジュールを含むことになるとビルドが失敗します。
  • siteverify はフォームエンコードのボディを受け取ります。ホスト名のチェックは訪問者が送信したホストと比較し、開発中はスキップします。テスト用のトークンにはダミーのホスト名が入っているためです。
  • forward は元の FormData を送るので、ハニーポットを含むすべてのフィールドが送信されたとおりに Ikiform に届きます。
  • 訪問者の Origin も渡します。サーバー側の fetch はこれを送らず、Ikiform は無料の localhost テストを判定するためにこれを読むからです。
サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains と自身の人間確認をスキップします。すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分 5 件という Standard の制限は全訪問者の合計で数えられます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

ウィジェット

app/components/turnstile.tsx
import { useEffect, useRef } from "react";

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface TurnstileApi {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

export function Turnstile({ siteKey }: { siteKey: string }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    const mount = () => {
      if (box.current && window.turnstile) widgetId = window.turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    };
    if (window.turnstile) mount();
    else document.querySelector(`script[src="${SCRIPT}"]`)?.addEventListener("load", mount, { once: true });
    return () => {
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey]);

  return (
    <>
      <script src={SCRIPT} async />
      <div ref={box} />
    </>
  );
}

React 19 は async の <script> を head に移動し、コンポーネントが何度レンダリングされても 1 回だけ読み込みます。エフェクトでは、Cloudflare がシングルページアプリに推奨する明示的なレンダリングでウィジェットを描画し、アンマウント時に削除します。

ルート

app/routes/contact.tsx
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import { env } from "cloudflare:workers";
import { Turnstile } from "~/components/turnstile";
import { forward, isHuman } from "~/lib/spam.server";
import type { Route } from "./+types/contact";

const POLICY = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "object-src 'none'",
  "base-uri 'self'",
].join("; ");

export function loader() {
  return { siteKey: env.TURNSTILE_SITE_KEY };
}

export async function action({ request }: Route.ActionArgs) {
  const ip = request.headers.get("cf-connecting-ip") ?? "unknown";

  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  if (!success) {
    return data({ ok: false, error: "Too many messages. Try again in a minute." }, { status: 429, headers: { "Retry-After": "60" } });
  }

  const form = await request.formData();
  const token = String(form.get("cf-turnstile-response") ?? "");
  if (!(await isHuman(env.TURNSTILE_SECRET_KEY, token, ip, new URL(request.url).hostname))) {
    return data({ ok: false, error: "We couldn't confirm you're human. Try again." }, { status: 403 });
  }

  form.delete("cf-turnstile-response");
  const sent = await forward(env.IKIFORM_SECRET_KEY, form, request.headers.get("origin"));
  return sent ? { ok: true, error: "" } : data({ ok: false, error: "Your message didn't send. Try again." }, { status: 502 });
}

export function headers({ actionHeaders, parentHeaders }: Route.HeadersArgs) {
  parentHeaders.set("Content-Security-Policy", POLICY);
  const retry = actionHeaders.get("Retry-After");
  if (retry) parentHeaders.set("Retry-After", retry);
  return parentHeaders;
}

export default function Contact({ loaderData, actionData }: Route.ComponentProps) {
  const fetcher = useFetcher<typeof action>();
  const result = fetcher.data ?? actionData;
  const [round, setRound] = useState(0);

  useEffect(() => {
    if (fetcher.state === "idle" && fetcher.data) setRound((count) => count + 1);
  }, [fetcher.state, fetcher.data]);

  if (result?.ok) return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <fetcher.Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile key={round} siteKey={loaderData.siteKey} />
      {result?.error ? <p role="alert">{result.error}</p> : null}
      <button disabled={fetcher.state !== "idle"}>{fetcher.state === "idle" ? "Send message" : "Sending"}</button>
    </fetcher.Form>
  );
}
  • 制限は request.formData() より前に実行されるので、大量送信でボディの解析や siteverify の呼び出しのコストがかかることはありません。
  • cf-connecting-ip は Worker へのすべてのリクエストで Cloudflare が設定し、訪問者が設定することはできません。
  • data() はステータスと Retry-After を設定します。fetcher はボディを直接読みますが、それらのヘッダーをドキュメントのレスポンスに載せるのは headers エクスポートです。
  • result は actionData にフォールバックするので、ハイドレーション前にフォームが送信された場合でもエラーが表示されます。
  • .hp クラスは、ハニーポットのガイド と同じようにハニーポットを画面外に隠します。

React Router はハイドレーション用にインラインスクリプトを出力するので、このポリシーでは script-src に 'unsafe-inline' を残しています。外すには entry.server.tsx で nonce を生成し、<Scripts> と Turnstile のスクリプトに渡します。Cloudflare によると、Turnstile は読み込むものに nonce を引き継ぎます。

ローカルでテストする

Cloudflare のドキュメントによると、Vite プラグインは開発中もサーバーのコードを Workers ランタイムで実行するので、cloudflare:workers の env、.dev.vars、レート制限バインディングは本番と同じように動きます。ローカルでは cf-connecting-ip を設定する Cloudflare のエッジがないので、すべてのリクエストが unknown として 1 つの制限を共有します。これは想定どおりです。本物のヘッダーを確認するには、wrangler versions upload でプレビューをデプロイし、2 つのネットワークからテストしてください。

他の場所にデプロイする

ホストクライアント IP制限の仕組みシークレット
Cloudflare Workerscf-connecting-ipレート制限バインディングwrangler secret put
VercelVercel が上書きする x-forwarded-forRedisprocess.env
Netlifyx-nf-client-connection-ipRedisprocess.env

Cloudflare はこのバインディングを、概算でロケーションごとにカウントするものと説明しています。お問い合わせフォームならそれで十分です。Workers 以外には cloudflare:workers モジュールがないので、シークレットは process.env から読み、バインディングはレート制限のページにあるような Redis のカウンターに置き換えてください。

チェックを Ikiform に任せる

1 つのフォームのためにキー、バインディング、CSP を運用するのが大げさなら、通常のフォームの送信先を Ikiform のエンドポイントにして、Action を削除します:

app/routes/contact.tsx
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label htmlFor="email">Email</label>
  <input id="email" name="email" type="email" autoComplete="email" required />
  <label htmlFor="message">Message</label>
  <textarea id="message" name="message" rows={5} required />
  <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
  <button>Send message</button>
</form>

React Router の <Form> ではなく小文字の <form> を使うと、ブラウザーが直接 Ikiform に送信します。Spam check を Ikiform に設定すると、トークンのない送信には ikiform.com 上で Turnstile を実行してフィールドを送り届ける短い確認ページが表示されます。Rate limit で訪問者ごとに 10 分あたり 2 回の Strict を選び、Allowed domains にドメインを登録します。捕まえたスパムには本物のメッセージと同じ応答が返り、30 日間保持されます。自分の Action を残す方法はサーバー設定ガイドにあります。

動作を確認する

  1. npm run dev を実行し、テスト用のキーでメッセージを送ります。Submissions に表示されます。プランのないワークスペースでも、localhost からのテストを月 50 件まで受け付けます。
  2. 再読み込みせずに 2 通目のメッセージを送ります。1 通目の応答の後に新しいウィジェットが表示され、2 回目の送信も成功します。
  3. JavaScript を無効にして送信します。ページが再読み込みされ、人間確認のエラーが表示されます。
  4. デプロイ後に 1 分間で 6 回送信します。6 回目には Retry-After: 60 付きの 429 が返ります。
  5. ハニーポットを _gotcha=bot と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。

ハマりどころ

問題対処
Server-only module referenced by clientコンポーネントが、描画するものを spam.server.ts からインポートしています。これを使えるのは loader と action だけです。
2 回目の試行が必ず siteverify で失敗するウィジェットが使用済みのトークンを保持しています。fetcher が応答するたびに key が変わるようにしてください。
Invalid context value provided to handleRequestWorker のエントリーが、React Router 7 のテンプレートのように { cloudflare: { env, ctx } } をまだ渡しています。リクエストだけでハンドラーを呼び、バインディングは cloudflare:workers から読んでください。
JavaScript なしのレスポンスに Retry-After がないAction のヘッダーは、headers エクスポートが返さない限り破棄されます。例のように actionHeaders からコピーしてください。

よくある質問

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