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

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

Turnstile を使った TanStack Start フォームのスパム対策

TanStack Start のフォームをスパムから守る方法。クライアント IP 用の関数ミドルウェア、トークンバケット、サーバー関数での Turnstile 検証、CSP。

Preet SutharIkiform 創業者

クライアント IP とオリジンを 1 回だけ読み、コンテキストとしてすべてのサーバー関数に渡す関数ミドルウェアを追加します。サーバー関数では入力を検証し、IP ごとのバケットからトークンを 1 つ取り、siteverify で Turnstile のトークンを検証してから、ハニーポットと一緒にフィールドを転送します。Start が組み込む CSRF ミドルウェアは残し、src/start.ts を作成する場合は自分で追加し直してください。

TanStack Start ならではの違い

サーバー関数は同一オリジンの RPC エンドポイントです。Start は Sec-Fetch-Site、Origin、Referer を確認する CSRF ミドルウェアで他のサイトから保護しており、別のページが訪問者のブラウザー経由で送信することを防ぎます。ただし、好きなヘッダーを送れるスクリプトは防げません。そのため、スパムのチェックはやはり関数の中に置く必要があります。

これを実現する Start 自身の部品がミドルウェアです。関数ミドルウェアはバリデーターとハンドラーより前に実行され、リクエストヘッダーを読み、context を通じてハンドラーに値を渡せます。IP の取得を 1 回書けば、サイトのすべてのフォームで使えます。

TanStack Start のサーバー関数の前に並ぶミドルウェアの連鎖自分のコードの前サーバー関数の中POST/_serverFnフォームかスクリプトからCSRFミドルウェア同一オリジン?訪問者ミドルウェアcontext:ip, originバリデーター空白除去、長さ制限トークンバケット3 回まで、20秒ごとに 1 回回復siteverifyaction がsupportIkiform保存、ハニーポットを評価結果:後でリトライはいトークンあり人間空TanStack Start のサーバー関数の前に並ぶミドルウェアの連鎖自分のコードの前サーバー関数の中POST/_serverFnフォームかスクリプトからCSRF ミドルウェア同一オリジン?訪問者ミドルウェアcontext: ip, originバリデーター空白除去、長さ制限トークンバケット3 回まで、20 秒ごとに 1 回回復siteverifyaction が supportIkiform保存、ハニーポットを評価結果:後でリトライはいトークンあり人間空
  1. ブラウザーかスクリプトが、サーバー関数の URL に送信します。
  2. Start の CSRF ミドルウェアは、自分のオリジンから来たと証明できない送信を拒否します。
  3. 訪問者ミドルウェアがクライアント IP と Origin ヘッダーを読み、コンテキストとして渡します。
  4. バリデーターが、ハンドラーに渡る前に入力を正規化します。
  5. ハンドラーはその IP のバケットからトークンを 1 つ取ります。残っていなければ、後でリトライするよう伝える結果を返します。
  6. Turnstile のトークンを検証してから、フィールドを Ikiform に転送します。Ikiform が保存し、ハニーポットを評価します。
各層は次の層よりコストが低く、拒否された送信が siteverify や Ikiform に届くことはありません。

プロジェクトの準備

ファイル
src/
  routes/
    support.tsx
  lib/
    use-turnstile.ts
  server/
    bucket.ts
    visitor.ts
    support.ts
  start.ts
.env
.env
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-forwarded-for
  • Vite がブラウザーに公開するのは VITE_ で始まる変数だけで、import.meta.env から読みます。シークレットには接頭辞がないので、サーバー上の process.env にしか存在しません。
  • テスト用のキーは localhost を含むどのホストでも通ります。環境ごとに本物のキーに差し替えてください。

クライアント IP 用の関数ミドルウェア

src/server/visitor.ts
import { createMiddleware } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-forwarded-for";

export const visitor = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const ip = getRequestHeader(HEADER)?.split(",")[0]?.trim() || "unknown";
  return next({ context: { ip, origin: getRequestHeader("origin") ?? null } });
});

CLIENT_IP_HEADER には、ホストが上書きするヘッダーを設定します。Vercel では x-forwarded-for、Netlify では x-nf-client-connection-ip(Netlify のスタッフが推奨しているものですが、ドキュメントに記載があるのは Functions の context.ip だけです)、Cloudflare では cf-connecting-ip です。nginx の背後にある自前の Node サーバーなら、nginx が設定するヘッダーを使い、nginx を経由せずにアプリへ到達できないようにしてください。このミドルウェアは訪問者の Origin も渡します。サーバー側から Ikiform への fetch はこれを送らず、Ikiform は無料の localhost テストを判定するためにこれを読むからです。

IP ごとのトークンバケット

固定ウィンドウでは、スクリプトが 1 分の終わりに上限いっぱいまで送り、次の 1 分の始めにもう一度送れてしまいます。トークンバケットは 1 回分ずつ回復するので、入力ミスを直す人が待つのは 1 分ではなく数秒です:

src/server/bucket.ts
const CAPACITY = 3;
const REFILL_MS = 20_000;
const buckets = new Map<string, { tokens: number; at: number }>();

export function take(key: string, now = Date.now()) {
  const saved = buckets.get(key) ?? { tokens: CAPACITY, at: now };
  const tokens = Math.min(CAPACITY, saved.tokens + (now - saved.at) / REFILL_MS);
  if (tokens < 1) {
    buckets.set(key, { tokens, at: now });
    return { ok: false, retryAfter: Math.ceil(((1 - tokens) * REFILL_MS) / 1000) };
  }
  if (buckets.size > 50_000) buckets.clear();
  buckets.set(key, { tokens: tokens - 1, at: now });
  return { ok: true, retryAfter: 0 };
}

このカウンターはメモリ上にあり、コンテナで動かす node .output/server/index.mjs のような長時間動く単一サーバーなら正確です。Vercel や Netlify では関数のインスタンスごとに別々の Map を持つので、実際の上限はわかりません。その場合はレート制限のページのようにバケットを Redis に移し、take(key) の形はそのままにします。

サーバー関数

src/server/support.ts
import { createServerFn } from "@tanstack/react-start";
import { take } from "./bucket";
import { visitor } from "./visitor";

const TOPICS = ["billing", "bug", "account", "other"];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export interface SupportInput {
  email: string;
  topic: string;
  message: string;
  _gotcha: string;
  token: string;
}

export type SupportResult = { ok: true } | { ok: false; field?: "email" | "message"; message: string };

async function isHuman(token: string, ip: string) {
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; action?: string };
    return verdict.success && verdict.action === "support";
  } catch {
    return false;
  }
}

export const sendSupportRequest = createServerFn({ method: "POST" })
  .middleware([visitor])
  .validator((input: SupportInput) => ({
    email: String(input.email ?? "").trim(),
    topic: TOPICS.includes(input.topic) ? input.topic : "other",
    message: String(input.message ?? "").trim().slice(0, 5000),
    _gotcha: String(input._gotcha ?? ""),
    token: String(input.token ?? "").slice(0, 2048),
  }))
  .handler(async ({ data, context }): Promise<SupportResult> => {
    if (!EMAIL.test(data.email)) return { ok: false, field: "email", message: "Enter an address we can reply to." };
    if (data.message.length < 10) return { ok: false, field: "message", message: "Tell us a little more." };

    const limit = take(context.ip);
    if (!limit.ok) return { ok: false, message: `Too many requests. Try again in ${limit.retryAfter} seconds.` };
    if (!(await isHuman(data.token, context.ip))) return { ok: false, message: "We couldn't confirm you're human." };

    const { token: _token, ...fields } = data;
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(context.origin ? { Origin: context.origin } : {}),
      },
      body: JSON.stringify(fields),
    }).catch(() => null);
    return response?.ok ? { ok: true } : { ok: false, message: "Your request didn't send. Try again." };
  });
  • バリデーターはハンドラーより前に実行され、入力を正規化するだけです。フィールドエラーは、フォームが該当する入力欄の横に表示できる結果として返ります。
  • 安いチェックから先に行います。メールアドレスと長さのチェックはコストゼロ、バケットは Map の参照 1 回、siteverify はネットワーク呼び出し 1 回です。
  • action は support でなければなりません。同じサイトキーで別のウィジェット用に発行されたトークンは拒否されます。
  • ハニーポットの _gotcha はここではチェックせずに転送するので、入力されていればスパムとして保存され、後から確認できます。
サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains と自身の人間確認をスキップします。すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分 5 件という Standard の制限は全訪問者の合計で数えられます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

ルートとウィジェット

ウィジェットは小さなフックから明示的にレンダリングします。モジュールレベルの Promise によって、クライアント側のナビゲーションでルートが何度マウントされてもスクリプトは 1 回しか読み込まれず、トークンは Turnstile の callback で届きます:

src/lib/use-turnstile.ts
import { useCallback, useEffect, useRef, useState } from "react";

interface Api {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: Api }).turnstile;
      if (api) resolve(api);
      else reject(new Error("Turnstile did not load"));
    };
    script.onerror = () => reject(new Error("Turnstile did not load"));
    document.head.append(script);
  });
  return loading;
}

export function useTurnstile(sitekey: string, action: string) {
  const ref = useRef<HTMLDivElement>(null);
  const api = useRef<Api | null>(null);
  const widget = useRef<string | null>(null);
  const [token, setToken] = useState("");

  useEffect(() => {
    let live = true;
    load().then((turnstile) => {
      if (!live || !ref.current) return;
      api.current = turnstile;
      widget.current = turnstile.render(ref.current, {
        sitekey,
        action,
        callback: setToken,
        "expired-callback": () => setToken(""),
      });
    });
    return () => {
      live = false;
      if (widget.current) api.current?.remove(widget.current);
      widget.current = null;
    };
  }, [sitekey, action]);

  const reset = useCallback(() => {
    setToken("");
    if (widget.current) api.current?.reset(widget.current);
  }, []);

  return { ref, token, reset };
}
src/routes/support.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { useTurnstile } from "../lib/use-turnstile";
import { type SupportResult, sendSupportRequest } from "../server/support";

export const Route = createFileRoute("/support")({ component: Support });

function Support() {
  const send = useServerFn(sendSupportRequest);
  const turnstile = useTurnstile(import.meta.env.VITE_TURNSTILE_SITE_KEY, "support");
  const [result, setResult] = useState<SupportResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    setPending(true);
    try {
      setResult(
        await send({
          data: {
            email: String(form.get("email") ?? ""),
            topic: String(form.get("topic") ?? ""),
            message: String(form.get("message") ?? ""),
            _gotcha: String(form.get("_gotcha") ?? ""),
            token: turnstile.token,
          },
        }),
      );
    } catch {
      setResult({ ok: false, message: "Couldn't reach the server. Try again." });
    } finally {
      setPending(false);
      turnstile.reset();
    }
  }

  if (result?.ok) return <p role="status">Got it. We'll reply by email within a day.</p>;
  const error = result && !result.ok ? result : null;

  return (
    <form onSubmit={onSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" aria-invalid={error?.field === "email"} />
      <label htmlFor="topic">Topic</label>
      <select id="topic" name="topic" defaultValue="other">
        <option value="billing">Billing</option>
        <option value="bug">Something is broken</option>
        <option value="account">My account</option>
        <option value="other">Something else</option>
      </select>
      <label htmlFor="message">What happened?</label>
      <textarea id="message" name="message" rows={6} aria-invalid={error?.field === "message"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hp" />
      <div ref={turnstile.ref} />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={pending || !turnstile.token}>{pending ? "Sending" : "Send request"}</button>
    </form>
  );
}
  • Turnstile がトークンを渡すまでボタンは無効のままなので、空のトークンで送信されることはありません。
  • turnstile.reset() は毎回の送信後に実行します。トークンは 1 回しか使えず有効期間は 300 秒なので、リトライには新しいトークンが必要です。
  • このフォームには JavaScript が必要です。サーバー関数は .url 経由で通常のフォーム送信も受けられますが、Turnstile のウィジェットはどのみちブラウザーを必要とします。

src/start.ts のセキュリティヘッダー

Start には独自の CSP オプションがありません。src/start.ts のグローバルなリクエストミドルウェアで、すべてのレスポンスに設定できます。このファイルを作成すると Start がデフォルトで組み込む CSRF ミドルウェアが無効になるので、まずそれを追加し直してください:

src/start.ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { setResponseHeaders } from "@tanstack/react-start/server";

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'",
  "frame-ancestors 'none'",
].join("; ");

const securityHeaders = createMiddleware().server(async ({ next }) => {
  setResponseHeaders(new Headers({ "Content-Security-Policy": policy }));
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }), securityHeaders],
}));
  • Start がサーバーレンダリングする HTML にはインラインスクリプトが含まれます。nonce の仕組みがなければ script-src に 'unsafe-inline' が必要ですが、それでも列挙していないホストからのスクリプトはブロックされます。
  • Turnstile は challenges.cloudflare.com の iframe でウィジェットを描画するので、frame-src で許可する必要があります。
  • CSRF の filter でチェックをサーバー関数に限定しているので、通常のページ読み込みには影響しません。

デプロイ

ホストCLIENT_IP_HEADER制限の仕組み
自前の Node サーバーやコンテナプロキシが設定するものメモリ上で動く
Vercelx-forwarded-forRedis
Netlifyx-nf-client-connection-ipRedis
Cloudflare Workerscf-connecting-ipWorkers のレート制限バインディング

Vercel は x-forwarded-for を訪問者のアドレスで上書きすると明記しています。デプロイ前に、本番とプレビューのホスト名をすべて Turnstile ウィジェットに登録してください。

チェックを Ikiform に任せる

Turnstile のキーとリミッターを自分で運用したくなければ、サーバー関数をやめてブラウザーから送信します。onSubmit の send 呼び出しを、確認と送信に置き換えます。box はウィジェットの代わりに置く空の div への ref です:

src/routes/support.tsx
import { challenge, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const turnstileToken = await challenge(FORM_ID, { container: box.current ?? undefined });
const result = await submit(FORM_ID, new FormData(event.currentTarget), { turnstileToken });

フォームの Spam check を Ikiform に設定し、Rate limit で訪問者ごとに 10 分あたり 2 回の Strict を選び、Allowed domains にドメインを追加します。すると Ikiform がハニーポット、ikiform.com 上の Turnstile、IP ごとの制限、内容のスコアリングを行い、捕まえたものを 30 日間保持します。CSP では Cloudflare の代わりに https://ikiform.com を frame-src と connect-src に入れてください。サーバー関数を残す場合のキーについてはサーバー設定ガイドを参照してください。

動作を確認する

  1. テスト用のキーで開発サーバーを起動し、リクエストを送ります。Submissions に表示されます。プランがなくても、localhost からのテストは月 50 件まで無料です。
  2. 続けて 4 回リクエストを送ります。4 回目は秒数付きで、後でリトライするよう伝えるメッセージを返します。
  3. TURNSTILE_SECRET_KEY を常に失敗するシークレットにします。どのリクエストでも人間確認のエラーが表示されます。
  4. ネットワークタブからサーバー関数の呼び出しを curl で Origin なしに再送します。CSRF ミドルウェアが拒否します。
  5. ハニーポットを _gotcha=bot と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。

ハマりどころ

問題対処
src/start.ts を追加したら、クロスサイトの送信がサーバー関数に届くようになった独自の src/start.ts はデフォルトの CSRF ミドルウェアを置き換えます。例のように createCsrfMiddleware() を requestMiddleware に追加してください。
すべての訪問者が 1 つのバケットを共有しているCLIENT_IP_HEADER に、ホストが設定しないヘッダーが指定されているため、すべての IP が unknown になっています。上の表のヘッダーを使ってください。
Vercel で制限がランダムにリセットされるように見える関数のインスタンスごとにメモリが別々です。バケットを Redis に移してください。
開発環境でウィジェットが 2 回描画されるReact の Strict Mode はエフェクトを 2 回マウントします。クリーンアップで最初のウィジェットが削除されるので、本番では 1 つだけ表示されます。

よくある質問

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