お問い合わせフォームのスパムを止める3 分で読めます
Turnstile を使った Astro フォームのスパム対策
Action で Astro のフォームをスパムから守ります。ハニーポット、astro:env のシークレットで確認する Turnstile、IP ごとの制限、Astro 組み込みの CSP。
チェックは、フォームが JavaScript なしで送信する Astro Action に置きます。context.clientAddress で制限をかけ、astro:env/server のシークレットで Turnstile のトークンを検証してから、ハニーポットとともにフィールドを転送します。お問い合わせページはオンデマンドでレンダリングする必要があるので、アダプターを追加し、そのページに prerender = false を設定します。サイト全体を静的なままにしたいなら、通常のフォームを Ikiform に送信し、同じチェックを Ikiform に任せてください。
Astro ならではの違い
Astro はデフォルトで静的 HTML をビルドしますが、静的 HTML は秘密を守れません。Turnstile のシークレット、Ikiform のシークレットキー、レート制限のカウンターはどれもリクエストごとに動くコードを必要とするので、Astro サイトで最初に決めるのは、お問い合わせページをそもそもオンデマンドでレンダリングするかどうかです。
- お問い合わせページのレンダリング方法を決めます。
- 自分の Turnstile キーを使う場合、ページはアダプター経由でオンデマンドにレンダリングします。
- フォームは Astro Action に送信し、Action が IP で制限をかけ、トークンを検証し、フィールドを転送します。
- Ikiform がメッセージを保存し、ハニーポットをスコア判定します。
- サイトを静的なままにするなら、通常のフォームが Ikiform に直接送信し、Ikiform がハニーポット、マネージドの Turnstile チェック、レート制限を実行します。
Astro はデフォルトで、security.checkOrigin によりオンデマンドのページへのフォーム送信のオリジンも確認します。これで他のサイトのページからあなたのサイトへの送信は止まりますが、好きなヘッダーを送れるスクリプトは止まらないので、以下のチェックは依然として必要です。
プロジェクトの準備
src/
actions/
index.ts
lib/
limit.ts
turnstile.ts
pages/
contact.astro
astro.config.mjs
.envnpx astro add vercel、netlify、cloudflare でアダプターを追加します。すべての変数を astro: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_tokenimport 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 つ持つので、複数のサーバーレスインスタンスをまたいで動きます:
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 の代わりになりますが、カウントはロケーションごとの概算になります:
import { env } from "cloudflare:workers";
export async function overLimit(ip: string) {
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
return !success;
}トークンを検証する
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
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_SECRET_KEY はサーバー専用の環境変数に置いてください。ページ
---
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/cloudflare | wrangler secret put | Workers のレート制限バインディング |
デプロイ前に本番のホスト名を Turnstile のウィジェットに追加し、プレビューでテストするならそのホスト名も追加してください。
静的なままにして Ikiform にチェックさせる
Astro サイトの多くは静的で、お問い合わせフォームのためだけにアダプターを追加するのは無視できないコストです。アダプターなしなら、フォームを Ikiform のエンドポイントに直接送信します:
---
---
<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 のセットアップガイドにあります。
動作を確認する
- テスト用キーで
astro devを実行し、メッセージを送ります。Submissions に表示されます。プランのないワークスペースでも、localhostからのテストを月 50 件まで受け付けます。 - devtools で
required属性を外し、名前を空のままにします。入力欄の下にフィールドエラーが表示されます。 TURNSTILE_SECRET_KEYを常に失敗するシークレットにして送信します。ページに人間確認のエラーが表示されます。- 1 分間に 6 回送信します。6 回目は制限のメッセージ付きで
429を返します。 - ハニーポットを
_gotcha=botと埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 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 が送られます。 |
よくある質問
フォーム Action としては動きません。Action に送信するフォームには、アダプター経由でオンデマンドにレンダリングされるページが必要です。完全に静的なサイトなら、代わりに Ikiform などのフォームバックエンドにフォームを送信してください。
Action では context.clientAddress を、ページでは Astro.clientAddress を使います。アダプターがプラットフォームから値を埋めます。オンデマンドでレンダリングされるルートにしか存在しません。
はい。Astro 6 で安定版になった security.csp を使います。scriptDirective.resources の 'self' の隣に https://challenges.cloudflare.com を追加し、同じホストの frame-src ディレクティブも追加します。開発環境では動かないので、本番ビルドでテストしてください。
いいえ、無料プランはありません。テストは無料で、支払い前でも localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com