お問い合わせフォームのスパムを止める3 分で読めます
Turnstile を使った React Router フォームのスパム対策
React Router のフォームをスパムから守る方法。Turnstile 付きのルート Action、cf-connecting-ip をキーにした Workers のレート制限、.server モジュール、CSP ヘッダー。
チェックはルートの 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 についてはデプロイの表で説明します。
- ハイドレーション後は、フォームが useFetcher で送信するので、ページは遷移しません。
- Action が IP でレート制限し、Turnstile のトークンを検証して、フィールドを転送します。
- 結果は fetcher.data として届きます。
- ウィジェットの key が変わるので、次の試行に向けて新しいウィジェットと新しいトークンが用意されます。
- ハイドレーション前は、同じフォームが Turnstile のトークンなしの通常のドキュメントリクエストとして送信されます。
- Action は siteverify の段階でそれを拒否します。
- headers エクスポートが、Retry-After と CSP をドキュメントのレスポンスに引き渡します。
- コンポーネントは fetcher.data の代わりに actionData を読み、エラーを表示します。
プロジェクトの準備
@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.varsimport { 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>;{
"name": "acme-site",
"main": "./workers/app.ts",
"vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
"ratelimits": [
{
"name": "CONTACT_LIMIT",
"namespace_id": "1001",
"simple": { "limit": 5, "period": 60 }
}
]
}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とシークレットに型が付くようにします。
サーバー専用のヘルパー
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_SECRET_KEY はサーバー専用の環境変数に置いてください。ウィジェット
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 がシングルページアプリに推奨する明示的なレンダリングでウィジェットを描画し、アンマウント時に削除します。
ルート
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 Workers | cf-connecting-ip | レート制限バインディング | wrangler secret put |
| Vercel | Vercel が上書きする x-forwarded-for | Redis | process.env |
| Netlify | x-nf-client-connection-ip | Redis | process.env |
Cloudflare はこのバインディングを、概算でロケーションごとにカウントするものと説明しています。お問い合わせフォームならそれで十分です。Workers 以外には cloudflare:workers モジュールがないので、シークレットは process.env から読み、バインディングはレート制限のページにあるような Redis のカウンターに置き換えてください。
チェックを Ikiform に任せる
1 つのフォームのためにキー、バインディング、CSP を運用するのが大げさなら、通常のフォームの送信先を Ikiform のエンドポイントにして、Action を削除します:
<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 を残す方法はサーバー設定ガイドにあります。
動作を確認する
npm run devを実行し、テスト用のキーでメッセージを送ります。Submissions に表示されます。プランのないワークスペースでも、localhostからのテストを月 50 件まで受け付けます。- 再読み込みせずに 2 通目のメッセージを送ります。1 通目の応答の後に新しいウィジェットが表示され、2 回目の送信も成功します。
- JavaScript を無効にして送信します。ページが再読み込みされ、人間確認のエラーが表示されます。
- デプロイ後に 1 分間で 6 回送信します。6 回目には
Retry-After: 60付きの429が返ります。 - ハニーポットを
_gotcha=botと埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
ハマりどころ
| 問題 | 対処 |
|---|---|
Server-only module referenced by client | コンポーネントが、描画するものを spam.server.ts からインポートしています。これを使えるのは loader と action だけです。 |
| 2 回目の試行が必ず siteverify で失敗する | ウィジェットが使用済みのトークンを保持しています。fetcher が応答するたびに key が変わるようにしてください。 |
Invalid context value provided to handleRequest | Worker のエントリーが、React Router 7 のテンプレートのように { cloudflare: { env, ctx } } をまだ渡しています。リクエストだけでハンドラーを呼び、バインディングは cloudflare:workers から読んでください。 |
JavaScript なしのレスポンスに Retry-After がない | Action のヘッダーは、headers エクスポートが返さない限り破棄されます。例のように actionHeaders からコピーしてください。 |
よくある質問
ルートのコンポーネントでウィジェットを描画し、Action でフォームデータから cf-turnstile-response を読み、シークレットキーと一緒に siteverify に送ります。応答が成功で、ウィジェットに設定した action と一致する場合にだけ処理を続けてください。
wrangler.jsonc でレート制限バインディングを宣言し、Action の冒頭で cf-connecting-ip ヘッダーから作ったキーで limit を呼びます。失敗したら data() で 429 を返します。
ドキュメントリクエストでは、React Router はルートの headers エクスポートが返すヘッダーだけを送ります。そこで actionHeaders を読み、Retry-After など必要なものをコピーしてください。
いいえ、無料プランはありません。テストは無料で、支払い前でも localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com