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

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

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

SvelteKit のフォームをスパムから守る方法。getClientAddress を使うフォーム Action、IP ごとの制限、use:enhance の裏の Turnstile、それを許可する CSP。

Preet SutharIkiform 創業者

チェックはフォーム Action で行います。getClientAddress() で制限し、siteverify で Turnstile のトークンを検証してから、ハニーポットと一緒にフィールドを転送します。トークンは 1 回しか使えないので、ウィジェットは Svelte のアクションで描画し、use:enhance のコールバックでリセットします。sveltekit() プラグインの csp オプションで challenges.cloudflare.com を許可し、adapter-node では本当の IP がどのプロキシヘッダーにあるかを SvelteKit に伝えます。

SvelteKit ならではの違い

SvelteKit は訪問者の IP を直接渡してくれます。リクエストイベントの getClientAddress() です。この値の出どころはアダプターによって異なり、adapter-node のデフォルトはプロキシの背後では誤った値になります。そこさえ正しくすれば、残りのチェックはフォーム Action にきれいに収まります。

アダプターごとに getClientAddress() が訪問者の IP をどこから取得するかフォーム ActiongetClientAddress() を呼ぶ自分のアダプターadapter-nodeソケットのアドレスかADDRESS_HEADERadapter-vercelVercel のエッジからadapter-netlifyNetlify のエッジからadapter-cloudflarecf-connecting-ip からアダプターごとに getClientAddress() が訪問者の IP をどこから取得するかフォーム ActiongetClientAddress()を呼ぶ自分のアダプターadapter-nodeソケットのアドレスかADDRESS_HEADERadapter-vercelVercel のエッジからadapter-netlifyNetlify のエッジからadapter-cloudflarecf-connecting-ipから
  1. フォーム Action がリクエストイベントにクライアントのアドレスを尋ねます。
  2. SvelteKit は、デプロイに使うアダプターに答えを委ねます。
  3. adapter-node は、ADDRESS_HEADER と XFF_DEPTH を設定しない限りソケットのリモートアドレスを使います。
  4. Vercel、Netlify、Cloudflare のアダプターはプラットフォームが渡すアドレスを読むので、設定は不要です。
nginx やロードバランサーの背後では、ADDRESS_HEADER を設定するまで adapter-node にはすべての訪問者がプロキシのアドレスに見えます。共有された 1 つのアドレスのせいで、IP ごとの制限がサイト全体の制限になってしまいます。

SvelteKit のもう 1 つの細かい点は use:enhance です。ページを再読み込みせずに送信するので利用者には快適ですが、Turnstile が自動で再描画されることはありません。応答のたびに手動でウィジェットをリセットする必要があります。

プロジェクトの準備

ファイル
src/
  env.ts
  lib/
    turnstile.ts
    server/
      spam.ts
  routes/
    contact/
      +page.server.ts
      +page.svelte
vite.config.ts
.env
.env
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token
src/env.ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  PUBLIC_TURNSTILE_SITE_KEY: { public: true },
  TURNSTILE_SECRET_KEY: {},
  IKIFORM_SECRET_KEY: {},
  UPSTASH_REDIS_REST_URL: {},
  UPSTASH_REDIS_REST_TOKEN: {},
});
  • SvelteKit 3 は src/env.ts で宣言した変数を読みます。public: true を持つのはサイトキーだけなので、$app/env/public がブラウザーに渡せるのはそれだけです。src/lib/server など server ディレクトリ内のものはクライアントのコードからは一切インポートできず、試みると SvelteKit がビルドを失敗させます。
  • 宣言した変数は、static: true を設定しない限りアプリの起動時に読まれるので、キーを更新したら再ビルドではなく再起動が必要です。$env/* モジュールも動きますが、SvelteKit 3 では非推奨です。
  • Cloudflare のテスト用キーは localhost を含むどのホストでも通ります。
vite.config.ts
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    sveltekit({
      adapter: adapter(),
      csp: {
        mode: "auto",
        directives: {
          "script-src": ["self", "https://challenges.cloudflare.com"],
          "frame-src": ["https://challenges.cloudflare.com"],
          "object-src": ["none"],
          "base-uri": ["self"],
        },
      },
    }),
  ],
});

SvelteKit 3 のオプションは vite.config.ts で sveltekit() プラグインに渡します。svelte.config.js はもう読まれません。mode: "auto" にすると、SvelteKit はサーバーレンダリングのページでは自身のインラインスクリプトに nonce を、プリレンダーのページではハッシュを付けるので、ポリシーに 'unsafe-inline' は不要です。Turnstile は script-src で許可したホストから読み込まれ、frame-src で許可した iframe を描画します。

サーバー専用のヘルパー

src/lib/server/spam.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { dev } from "$app/env";
import { TURNSTILE_SECRET_KEY, UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "$app/env/private";

const limiter = new Ratelimit({
  redis: new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function secondsToWait(ip: string) {
  const { success, reset } = await limiter.limit(ip);
  return success ? 0 : Math.max(1, Math.ceil((reset - Date.now()) / 1000));
}

export async function isHuman(token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (dev || verdict.hostname === host);
  } catch {
    return false;
  }
}
  • 制限は Redis 上のスライディングウィンドウで、すべてのサーバーインスタンスで共有され、IP ごとに 1 分あたり 5 通です。reset はミリ秒で返るので、待ち時間の秒数に変換します。
  • $app/env の dev を使い、ローカルではホスト名のチェックをスキップします。テスト用のトークンにはダミーのホスト名が入っているためです。
  • siteverify は、300 秒を過ぎたトークンや使用済みのトークンに timeout-or-duplicate を返します。だからページでウィジェットをリセットするのです。

フォーム Action

src/routes/contact/+page.server.ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { isHuman, secondsToWait } from "#lib/server/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const ip = getClientAddress();
    const wait = await secondsToWait(ip);
    if (wait) return fail(429, { wait, error: `Too many messages. Try again in ${wait} seconds.` });

    const form = await request.formData();
    const values = {
      name: String(form.get("name") ?? "").trim(),
      email: String(form.get("email") ?? "").trim(),
      message: String(form.get("message") ?? "").trim(),
    };
    if (values.message.length < 10) return fail(400, { values, error: "A few words more, please." });
    if (!(await isHuman(String(form.get("cf-turnstile-response") ?? ""), ip, url.hostname))) {
      return fail(403, { values, error: "We couldn't confirm you're human. Try again." });
    }

    form.delete("cf-turnstile-response");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: form,
    }).catch(() => null);
    if (!response?.ok) return fail(502, { values, error: "Your message didn't send. Try again." });
    return { sent: true };
  },
} satisfies Actions;
  • 制限はボディの解析より前に実行されます。fail(429) なら訪問者はメッセージを見ながらページにとどまります。
  • それ以外の失敗ではすべて values を返すので、エラーの後も入力欄に値が戻ります。
  • 元の FormData を、トークンを除いてハニーポットごと転送します。ハニーポットが埋まっていれば Ikiform が評価し、メッセージをスパムとして保存します。
  • 訪問者の Origin を渡します。サーバー側の fetch はこれを送らず、Ikiform は無料の localhost テストを判定するためにこれを読むからです。
サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains と自身の人間確認をスキップします。すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分 5 件という Standard の制限は全訪問者の合計で数えられます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

Svelte のアクションとしてのウィジェット

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

interface Api {
  render(container: HTMLElement, options: { sitekey: string; action: string }): 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 = SCRIPT;
    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 turnstile(node: HTMLElement, sitekey: string) {
  let api: Api | undefined;
  load().then((loaded) => {
    api = loaded;
    node.dataset.widgetId = loaded.render(node, { sitekey, action: "contact" });
  });
  return {
    destroy() {
      if (node.dataset.widgetId) api?.remove(node.dataset.widgetId);
    },
  };
}

export async function resetTurnstile(node: HTMLElement | undefined) {
  const id = node?.dataset.widgetId;
  if (id) (await load()).reset(id);
}

サードパーティのウィジェットの置き場所としては Svelte のアクションが自然です。要素のマウント時に実行され、要素がなくなるときに destroy が実行されます。ウィジェット ID は要素に保持するので、ページはその特定のウィジェットをリセットできます。

ページ

src/routes/contact/+page.svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import { resetTurnstile, turnstile } from "#lib/turnstile.js";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let box = $state<HTMLDivElement>();
  let sending = $state(false);
</script>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form
    method="POST"
    use:enhance={() => {
      sending = true;
      return async ({ update }) => {
        await update({ reset: false });
        sending = false;
        await resetTurnstile(box);
      };
    }}
  >
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required value={form?.values?.message ?? ""}></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div bind:this={box} use:turnstile={PUBLIC_TURNSTILE_SITE_KEY}></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
  </form>
{/if}
  • update({ reset: false }) は結果を反映し、訪問者が入力した内容を残します。デフォルトでは、ブラウザーと同じようにフォームがクリアされます。
  • resetTurnstile は、成功でも失敗でも応答のたびに実行します。使用済みのトークンは消え、新しいトークンが用意されます。
  • JavaScript がなくてもフォームは送信され Action も実行されますが、ウィジェットが描画されないので、siteverify がわかりやすいメッセージで拒否します。
  • フォーム Action のあるページはプリレンダーできないので、このルートは常にサーバーでレンダリングされます。他のページはプリレンダーのままで構いません。
  • .hp クラスは、ハニーポットのガイド と同じようにハニーポットを画面外に隠します。

ローカルでテストする

vite dev ではアプリの前にアダプターがないので、getClientAddress() はループバックアドレスを返し、ローカルのタブはすべて 1 つのバケットを共有します。メッセージの確認には問題ありませんが、プロキシ設定の確認にはなりません。それを確認するには、adapter-node でビルドし、本番と同じ変数で出力を実行します:

ターミナル
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node build

異なる X-Forwarded-For の値で何度か送信します。XFF_DEPTH=1 なら SvelteKit は一番右のアドレス、つまりプロキシが追加するアドレスを読むので、クライアントが左側にでっち上げた値は何も変えません。

デプロイ

自前のサーバーで 1 つのプロキシの背後に adapter-node を置く場合は、本当の IP がどこにあるかを SvelteKit に伝えます:

環境
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1

SvelteKit のドキュメントは、ヘッダーは簡単に偽装でき、X-Forwarded-For の一番左のアドレスを読むのが典型的な間違いだと警告しています。XFF_DEPTH は信頼するプロキシの数を右から数えるので、運用しているプロキシの数に設定し、プロキシを経由せずにアプリへ到達できないようにしてください。SvelteKit 3 では adapter-node の ORIGIN 変数が廃止されました。プロキシが元のホストを渡さない場合は、sveltekit() のオプションで paths.origin を設定し、SvelteKit 自身のフォームチェックが公開 URL を把握できるようにします。

アダプタークライアント IPシークレット
adapter-nodeADDRESS_HEADER と XFF_DEPTHプロセスの環境変数
adapter-vercel自動プロジェクトの環境変数
adapter-netlify自動サイトの環境変数
adapter-cloudflare自動wrangler secret put

チェックを Ikiform に任せる

これを一切自分で運用しないなら、+page.server.ts を削除し、use:enhance を使わずにフォームの action を Ikiform のエンドポイントに向けます。すると、そのページも静的サイトの他のページと同じようにプリレンダーできます。

フォームの Spam check を Ikiform に設定すると、トークンのない送信には ikiform.com 上で Turnstile を実行してフィールドを送り届ける短い確認ページが表示されます。Rate limit で訪問者ごとに 10 分あたり 2 回の Strict を選び、Allowed domains にドメインを登録します。Ikiform は捕まえたスパムを 30 日間保持するので、誤判定はクリック 1 回で復元できます。エンドポイントとキーについては SvelteKit の設定ガイドを参照してください。

動作を確認する

  1. テスト用のキーで vite dev を実行し、メッセージを送ります。Submissions に表示されます。プランのないワークスペースでも、localhost からのテストを月 50 件まで受け付けます。
  2. 再読み込みせずに 2 通目のメッセージを送ります。成功すれば、ウィジェットのリセットが機能しています。
  3. 1 分以内に 6 通のメッセージを送ります。6 通目では、あと何秒待てばよいかが表示されます。
  4. プロキシの背後で、2 つのネットワークから getClientAddress() をログに出します。プロキシのアドレスではなく、2 つの異なるアドレスが表示されます。
  5. ハニーポットを _gotcha=bot と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。

ハマりどころ

問題対処
adapter-node ですべての訪問者がまとめてレート制限されるgetClientAddress() がプロキシのアドレスを返しています。ADDRESS_HEADER と XFF_DEPTH を設定してください。
2 回目の送信が必ず人間確認で失敗するウィジェットが使用済みのトークンを保持しています。use:enhance のコールバックで、update の後に resetTurnstile を呼んでください。
Action のあるページがプリレンダーされてビルドが失敗するお問い合わせのルートがレイアウトから prerender = true を継承しています。Action のあるページはプリレンダーできないので、その +page.server.ts で export const prerender = false を設定してください。
ウィジェットの領域が空で、コンソールに CSP 違反が出るvite.config.ts の csp.directives で、script-src か frame-src に https://challenges.cloudflare.com がありません。

よくある質問

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