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

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

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

Action で Astro のフォームをスパムから守ります。ハニーポット、astro:env のシークレットで確認する Turnstile、IP ごとの制限、Astro 組み込みの CSP。

Preet SutharIkiform 創業者

チェックは、フォームが JavaScript なしで送信する Astro Action に置きます。context.clientAddress で制限をかけ、astro:env/server のシークレットで Turnstile のトークンを検証してから、ハニーポットとともにフィールドを転送します。お問い合わせページはオンデマンドでレンダリングする必要があるので、アダプターを追加し、そのページに prerender = false を設定します。サイト全体を静的なままにしたいなら、通常のフォームを Ikiform に送信し、同じチェックを Ikiform に任せてください。

Astro ならではの違い

Astro はデフォルトで静的 HTML をビルドしますが、静的 HTML は秘密を守れません。Turnstile のシークレット、Ikiform のシークレットキー、レート制限のカウンターはどれもリクエストごとに動くコードを必要とするので、Astro サイトで最初に決めるのは、お問い合わせページをそもそもオンデマンドでレンダリングするかどうかです。

Astro サイトでスパムチェックを動かす場所を選ぶお問い合わせページオンデマンドアダプター +prerender= falseAstroAction制限、siteverify、転送Ikiformシークレットキー、保存プレーンなHTMLフォームアダプターもJavaScriptも不要Ikiformハニーポット、Turnstile、制限自分のキーを使うフォームの POSTJSON静的なままにするPOSTAstro サイトでスパムチェックを動かす場所を選ぶお問い合わせページオンデマンドアダプター + prerender =falseAstro Action制限、siteverify、転送Ikiformシークレットキー、保存プレーンなHTMLフォームアダプターもJavaScriptも不要Ikiformハニーポット、Turnstile、制限自分のキーを使うフォームの POSTJSON静的なままにする
  1. お問い合わせページのレンダリング方法を決めます。
  2. 自分の Turnstile キーを使う場合、ページはアダプター経由でオンデマンドにレンダリングします。
  3. フォームは Astro Action に送信し、Action が IP で制限をかけ、トークンを検証し、フィールドを転送します。
  4. Ikiform がメッセージを保存し、ハニーポットをスコア判定します。
  5. サイトを静的なままにするなら、通常のフォームが Ikiform に直接送信し、Ikiform がハニーポット、マネージドの Turnstile チェック、レート制限を実行します。
どちらの方法でも止められるボットは同じです。上の方法では自分のウィジェットと自分の制限を使え、下の方法ではサーバーのコードを一切出荷しません。

Astro はデフォルトで、security.checkOrigin によりオンデマンドのページへのフォーム送信のオリジンも確認します。これで他のサイトのページからあなたのサイトへの送信は止まりますが、好きなヘッダーを送れるスクリプトは止まらないので、以下のチェックは依然として必要です。

プロジェクトの準備

ファイル
src/
  actions/
    index.ts
  lib/
    limit.ts
    turnstile.ts
  pages/
    contact.astro
astro.config.mjs
.env

npx astro add vercel、netlify、cloudflare でアダプターを追加します。すべての変数を astro: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
astro.config.mjs
import vercel from "@astrojs/vercel";
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      PUBLIC_TURNSTILE_SITE_KEY: envField.string({ context: "client", access: "public" }),
      TURNSTILE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_URL: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_TOKEN: envField.string({ context: "server", access: "secret" }),
    },
  },
  security: {
    csp: {
      scriptDirective: {
        resources: ["'self'", "https://challenges.cloudflare.com"],
      },
      directives: ["frame-src https://challenges.cloudflare.com"],
    },
  },
});
  • access: "secret" を付けた値はバンドルに含まれません。astro:env/server はクライアントのスクリプトでは読み込めません。
  • scriptDirective.resources は Astro のデフォルトを置き換えるので、Cloudflare と並べて 'self' も改めて記載します。Astro 自身のスクリプトは引き続きハッシュ化されます。
  • CSP は astro dev では動きません。astro build と astro preview で確認してください。

IP ごとの制限

Action の中では、context.clientAddress はアダプターがプラットフォームから取得した訪問者の IP なので、X-Forwarded-For を自分で解析する必要はありません。これはオンデマンドのルートにしか存在せず、プリレンダーしたルートでは例外を投げます。このカウンターは IP ごと 1 分ごとに Redis のキーを 1 つ持つので、複数のサーバーレスインスタンスをまたいで動きます:

src/lib/limit.ts
import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "astro:env/server";

const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });

export async function overLimit(ip: string, limit = 5, windowSeconds = 60) {
  const key = `contact:${ip}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
  const [count] = await redis.multi().incr(key).expire(key, windowSeconds).exec<[number, number]>();
  return count > limit;
}

Cloudflare では、Astro 7 は Astro.locals.runtime ではなく cloudflare:workers からバインディングを読みます。wrangler.jsonc で宣言した Workers のレート制限バインディングなら、ネットワーク呼び出しなしで Redis の代わりになりますが、カウントはロケーションごとの概算になります:

src/lib/limit.ts
import { env } from "cloudflare:workers";

export async function overLimit(ip: string) {
  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  return !success;
}

トークンを検証する

src/lib/turnstile.ts
import { TURNSTILE_SECRET_KEY } from "astro:env/server";

export async function isHuman(token: string, ip: string, host: string) {
  const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, 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 };
    if (!verdict.success || verdict.action !== "contact") return false;
    return import.meta.env.DEV || verdict.hostname === host;
  } catch {
    return false;
  }
}
  • siteverify はフォームエンコードのボディを受け付けるので、URLSearchParams にヘッダーは要りません。
  • ホスト名は、訪問者が実際に送信したホストである context.url.hostname と比較します。テスト用トークンにはダミーのホスト名が入るので、astro dev ではこのチェックをスキップします。
  • トークンの有効期間は 300 秒で、使えるのは 1 回だけです。通常のフォーム送信ではページが再読み込みされ、そのたびに Turnstile が新しいトークンを発行します。

Action

src/actions/index.ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
import { overLimit } from "../lib/limit";
import { isHuman } from "../lib/turnstile";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1, "Tell us your name.").max(120),
      email: z.email("Enter an address we can reply to."),
      message: z.string().trim().min(10, "A few words more, please.").max(5000),
      _gotcha: z.string().optional(),
      "cf-turnstile-response": z.string().max(2048).optional(),
    }),
    handler: async (input, context) => {
      const ip = context.clientAddress;
      if (await overLimit(ip)) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many messages. Try again in a minute." });
      }
      if (!(await isHuman(input["cf-turnstile-response"] ?? "", ip, context.url.hostname))) {
        throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try again." });
      }

      const { "cf-turnstile-response": _token, ...fields } = input;
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(fields),
      });
      if (!response.ok) throw new ActionError({ code: "BAD_GATEWAY", message: "Your message didn't send. Try again." });
      return { sent: true };
    },
  }),
};
  • Zod はハンドラーの前に実行されるので、形式の崩れた送信で Redis を呼ぶことはありません。フィールドエラーは isInputError でページに返ります。
  • ハニーポット _gotcha はスキーマ上は任意で、そのまま転送されます。埋められていれば Ikiform がスパムと判定するので、あとから確認できます。
  • トークンは転送前に取り除きます。検証済みのトークンは Ikiform には不要です。
  • サーバー側の fetch は Origin を送らないので、訪問者のものを一緒に渡します。Ikiform はこれを読んで無料の localhost テストを判別します。
  • TOO_MANY_REQUESTS は 429 に、FORBIDDEN は 403 に対応します。
サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains と自身の人間確認をスキップします。すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分 5 件という Standard の制限は全訪問者の合計で数えられます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

ページ

src/pages/contact.astro
---
import { actions, isInputError } from "astro:actions";
import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---

<main>
  <h1>Contact us</h1>
  {result?.data?.sent ? (
    <p role="status">Thanks. We'll reply by email.</p>
  ) : (
    <form method="POST" action={actions.sendMessage}>
      <label for="name">Name</label>
      <input id="name" name="name" autocomplete="name" required maxlength="120" />
      {fields.name && <p class="field-error">{fields.name[0]}</p>}
      <label for="email">Email</label>
      <input id="email" name="email" type="email" autocomplete="email" required />
      {fields.email && <p class="field-error">{fields.email[0]}</p>}
      <label for="message">Message</label>
      <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
      {fields.message && <p class="field-error">{fields.message[0]}</p>}
      <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 class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
      {result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
      <button>Send message</button>
    </form>
  )}
</main>

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

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>

フォームはクライアントの JavaScript なしで動きます。action={actions.sendMessage} はクエリ文字列にアクション名を付けて同じページに送信し、Astro がアクションを実行して、レンダリング中に Astro.getActionResult() が結果を読みます。オンデマンドでレンダリングするのはこのページだけで、サイトの残りは静的なままです。

  • 送信に失敗した後に再読み込みすると、フォームの再送信を求められます。それが気になるなら、Astro のドキュメントは、結果をセッションで運ぶ POST、リダイレクト、GET のパターンを勧めています。
  • Turnstile のスクリプトには is:inline を付けているので、Astro は手を加えません。Cloudflare 自身の URL から読み込む必要があります。
  • 静的なページのクライアントスクリプトからアクションを呼ぶこともできますが、その場合は試行のたびに自分で turnstile.reset() を呼ぶ必要があります。

デプロイ

ホストアダプターシークレットの置き場所制限
Vercel@astrojs/vercelプロジェクトの環境変数Redis
Netlify@astrojs/netlifyサイトの環境変数Redis
Cloudflare@astrojs/cloudflarewrangler secret putWorkers のレート制限バインディング

デプロイ前に本番のホスト名を Turnstile のウィジェットに追加し、プレビューでテストするならそのホスト名も追加してください。

静的なままにして Ikiform にチェックさせる

Astro サイトの多くは静的で、お問い合わせフォームのためだけにアダプターを追加するのは無視できないコストです。アダプターなしなら、フォームを Ikiform のエンドポイントに直接送信します:

src/components/Contact.astro
---
---

<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <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 minlength="10"></textarea>
  <div class="hp" aria-hidden="true">
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>
  <button>Send message</button>
</form>

フォームの Spam check を Ikiform にします。トークンのない送信には ikiform.com 上の短い確認ページが表示され、Turnstile を実行してフィールドを送り届けるので、ページにはスクリプトもキーも CSP の変更も要りません。Rate limit は Strict(訪問者ごとに 10 分あたり 2 件)を選び、Allowed domains に自分のドメインを追加します。スパムには本物のメッセージと同じ応答が返り、誤判定を救えるよう 30 日間保持されます。残りの手順は Astro のセットアップガイドにあります。

動作を確認する

  1. テスト用キーで astro dev を実行し、メッセージを送ります。Submissions に表示されます。プランのないワークスペースでも、localhost からのテストを月 50 件まで受け付けます。
  2. devtools で required 属性を外し、名前を空のままにします。入力欄の下にフィールドエラーが表示されます。
  3. TURNSTILE_SECRET_KEY を常に失敗するシークレットにして送信します。ページに人間確認のエラーが表示されます。
  4. 1 分間に 6 回送信します。6 回目は制限のメッセージ付きで 429 を返します。
  5. ハニーポットを _gotcha=bot と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
  6. astro build と astro preview を実行してページを開き、コンソールに CSP 違反が出ていないことを確認します。

ハマりどころ

問題対処
プリレンダーしたページで clientAddress が使えないページかアクションがビルド時に実行されています。お問い合わせページに export const prerender = false を追加してください。
アクションの結果が常に undefined になるフォームがプリレンダーしたページに送信しています。フォームのアクションは、オンデマンドでレンダリングするページでしか動きません。
開発環境では問題ないのに、本番でウィジェットが空になるCSP がブロックしています。scriptDirective.resources に https://challenges.cloudflare.com を記載し、frame-src でも許可する必要があります。
カスタムドメインからのすべての送信で、メッセージなしの 403 が返るsecurity.checkOrigin が別のオリジンを検出しています。よくあるのはプロキシがホストを書き換えている場合です。Astro に届く host ヘッダーがページのドメインと一致しているか確認してください。
送信データはダッシュボードにあるのに、メールも Webhook も届かないスパムとして保存されています。スパムは何も送りません。Spam フィルターで開いて理由を確認し、スパムではないとマークすればメールと Webhook が送られます。

よくある質問

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