お問い合わせフォームのスパムを止める3 分で読めます
Turnstile を使った Nuxt フォームのスパム対策
Nuxt のフォームをスパムから守る方法。Turnstile 付きの Nitro サーバールート、Nitro ストレージでの IP ごとの制限、runtimeConfig のシークレット、routeRules で設定する CSP。
フォームを $fetch でサーバールートに送信します。サーバールートは Nitro ストレージでクライアント IP ごとの試行回数を数え、siteverify で Turnstile のトークンを検証し、ハニーポットと一緒にフィールドを転送します。シークレットは非公開の runtimeConfig に置き、実行時に NUXT_ の環境変数から設定します。お問い合わせページの CSP は routeRules から送り、スクリプトとその iframe のために challenges.cloudflare.com を許可します。
Nuxt ならではの違い
Nuxt のサーバーは Nitro で、Nitro はスパムチェックに必要なものを 2 つ備えています。server/api のサーバールートは $fetch があらかじめつながった素のエンドポイントで、useStorage() は環境ごとにバックエンドを選べるキーバリューストアです。開発ではメモリ、本番では Redis などのドライバーを使えます。そのため、レート制限は依存パッケージの追加ではなく設定の変更で済みます。
ここでは Nuxt の 2 つの習慣が問題になります。getRequestIP(event, { xForwardedFor: true }) は X-Forwarded-For の最初の値を返しますが、h3 自身のドキュメントはこれをクライアントの入力と呼んでいます。また、本番では .env ファイルが読まれないので、シークレットはプラットフォームの環境変数として設定する必要があります。
- コンポーネントがフィールドと Turnstile のトークンを $fetch で /api/contact に送信します。
- サーバールートは、runtimeConfig で指定したヘッダーからクライアント IP を読みます。
- Nitro ストレージで、その IP の現在の 1 分間の送信数を数えます。5 回を超えたら、残り秒数付きで 429 を投げます。
- 上限内なら、siteverify でトークンを検証し、action とホストを確認します。
- 次にフィールドを Ikiform に転送します。Ikiform が保存し、ハニーポットを評価します。
プロジェクトの準備
app/
components/
ContactForm.vue
composables/
useTurnstile.ts
server/
api/
contact.post.ts
utils/
spam.ts
nuxt.config.ts
.envNUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_CLIENT_IP_HEADER=x-forwarded-forconst 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 default defineNuxtConfig({
runtimeConfig: {
turnstileSecretKey: "",
ikiformSecretKey: "",
clientIpHeader: "x-forwarded-for",
public: {
turnstileSiteKey: "",
},
},
routeRules: {
"/contact": { headers: { "Content-Security-Policy": policy } },
},
$production: {
nitro: {
storage: {
ratelimit: { driver: "upstash" },
},
},
},
});runtimeConfigのキーはすべて、実行時にNUXT_の変数で上書きできます。turnstileSecretKeyはNUXT_TURNSTILE_SECRET_KEYで、public.turnstileSiteKeyはNUXT_PUBLIC_TURNSTILE_SITE_KEYで上書きします。publicの外にあるキーはブラウザーに届きません。routeRulesは/contactでだけ CSP を送ります。Nuxt はペイロードとハイドレーション用のスクリプトをインラインで出力するので、このポリシーでは'unsafe-inline'を残しています。より厳しいポリシーが必要なら、コミュニティのnuxt-securityモジュールで nonce を生成できます。upstashドライバーは本番でだけマウントされ、環境からUPSTASH_REDIS_REST_URLとUPSTASH_REDIS_REST_TOKENを読みます。@upstash/redisパッケージが必要なので、npm install @upstash/redisでインストールしてください。
サーバーのユーティリティ
server/utils のファイルは、すべてのサーバールートに自動でインポートされます:
import type { H3Event } from "h3";
const WINDOW_SECONDS = 60;
const LIMIT = 5;
export function clientIp(event: H3Event) {
const header = useRuntimeConfig(event).clientIpHeader;
return getRequestHeader(event, header)?.split(",")[0]?.trim() || "unknown";
}
export async function secondsToWait(ip: string) {
const storage = useStorage("ratelimit");
const window = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
const key = `contact:${ip}:${window}`;
const count = ((await storage.getItem<number>(key)) ?? 0) + 1;
await storage.setItem(key, count);
if (count <= LIMIT) return 0;
return (window + 1) * WINDOW_SECONDS - Math.floor(Date.now() / 1000);
}
export async function isHuman(event: H3Event, token: string, ip: string) {
if (!token || token.length > 2048) return false;
try {
const verdict = await $fetch<{ success: boolean; hostname?: string; action?: string }>("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: { secret: useRuntimeConfig(event).turnstileSecretKey, response: token, remoteip: ip },
});
return verdict.success && verdict.action === "contact" && (import.meta.dev || verdict.hostname === getRequestHost(event));
} catch {
return false;
}
}clientIpが読むのは、ホストが上書きするヘッダー 1 つだけです。NUXT_CLIENT_IP_HEADERを、Vercel ではx-forwarded-for、Netlify ではx-nf-client-connection-ip、Cloudflare ではcf-connecting-ipに設定します。- カウンターは Nitro ストレージ上の固定ウィンドウです。読み取りと書き込みがアトミックではないので、同時に来た 2 つの送信がどちらも上限をすり抜けることがありますが、お問い合わせフォームなら許容範囲です。キーは自動では期限切れにならないので、Redis のデータベースに削除ポリシーを設定してください。
$fetchは siteverify のボディを JSON で送り、siteverify はそれを受け付けます。ホストのチェックはgetRequestHostを使い、開発中はスキップします。テスト用のトークンにはダミーのホスト名が入っているためです。
サーバールート
interface ContactBody {
name?: string;
email?: string;
company?: string;
message?: string;
_gotcha?: string;
"cf-turnstile-response"?: string;
}
export default defineEventHandler(async (event) => {
const ip = clientIp(event);
const wait = await secondsToWait(ip);
if (wait) throw createError({ status: 429, message: `Too many messages. Try again in ${wait} seconds.` });
const body = await readBody<ContactBody>(event);
const fields = {
name: body.name?.trim().slice(0, 120) ?? "",
email: body.email?.trim() ?? "",
company: body.company?.trim().slice(0, 120) ?? "",
message: body.message?.trim().slice(0, 5000) ?? "",
_gotcha: body._gotcha ?? "",
};
if (!fields.email.includes("@")) throw createError({ status: 400, message: "Enter an address we can reply to." });
if (!(await isHuman(event, body["cf-turnstile-response"] ?? "", ip))) {
throw createError({ status: 403, message: "We couldn't confirm you're human. Try again." });
}
const origin = getRequestHeader(event, "origin");
await $fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: fields,
}).catch((failure) => {
throw createError({
status: failure.status ?? 502,
message: failure.data?.error?.message ?? "Your message didn't send. Try again.",
});
});
return { sent: true };
});.post.tsの接尾辞により、Nitro はPOSTにだけ応答します。それ以外には405が返ります。- 制限は
readBodyより前に実行されるので、大量送信でボディの解析や siteverify の呼び出しのコストがかかることはありません。 - 失敗するたびに
createErrorを投げます。JSON のボディにmessageが入り、コンポーネントがそれを表示します。 - 訪問者の
Originを渡します。サーバー側の fetch はこれを送らず、Ikiform は無料のlocalhostテストを判定するためにこれを読むからです。 - ハニーポットの
_gotchaはそのまま渡すので、入力されていれば Ikiform は破棄せずにスパムとして保存します。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。コンポーザブルとフォーム
interface TurnstileApi {
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<TurnstileApi> | undefined;
function load() {
loading ??= new Promise<TurnstileApi>((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?: TurnstileApi }).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(action: string) {
const { turnstileSiteKey } = useRuntimeConfig().public;
const box = ref<HTMLElement>();
const token = ref("");
let api: TurnstileApi | undefined;
let widgetId: string | undefined;
onMounted(async () => {
api = await load();
if (!box.value) return;
widgetId = api.render(box.value, {
sitekey: turnstileSiteKey,
action,
callback: (value) => (token.value = value),
"expired-callback": () => (token.value = ""),
});
});
onBeforeUnmount(() => {
if (widgetId) api?.remove(widgetId);
});
function reset() {
token.value = "";
if (widgetId) api?.reset(widgetId);
}
return { box, token, reset };
}<script setup lang="ts">
const fields = reactive({ name: "", email: "", company: "", message: "", _gotcha: "" });
const { box, token, reset } = useTurnstile("contact");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
status.value = "sent";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
status.value = "idle";
reset();
}
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="fields.name" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" v-model="fields.email" type="email" autocomplete="email" required />
<label for="company">Company <span>(optional)</span></label>
<input id="company" v-model="fields.company" autocomplete="organization" maxlength="120" />
<label for="message">Message</label>
<textarea id="message" v-model="fields.message" rows="5" required minlength="10" />
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" v-model="fields._gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<div ref="box" />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
</form>
</template>- コンポーザブルは
onMountedでしか実行されないので、サーバーレンダリングでwindowに触れることはありません。 - ボタンはトークンを待ち、失敗するたびに
reset()で新しいトークンを要求します。トークンは 1 回しか使えず、有効期間は 300 秒だからです。 - このフォームには JavaScript が必要です。JavaScript なしで送信できる版は、次のセクションを参照してください。
.hpクラスは、ハニーポットのガイド と同じようにハニーポットを画面外に隠します。
ローカルでテストする
開発中の ratelimit のマウントはメモリ上なので、nuxt dev を再起動するとカウントがリセットされます。ブラウザーなしでも制限の動作を確認できます。トークンのない送信は siteverify で 403 になりますが、それでも 1 回ずつカウントされます:
for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/contact \
-H "Content-Type: application/json" -d '{"email":"test@example.com"}'
done最初の 5 行は 403 を、6 行目は 429 を出力します。ローカルでは IP ヘッダーを設定するプロキシがないので、すべてのリクエストが unknown として数えられます。これは想定どおりで、ヘッダーをコードではなく設定に置いているのはこのためです。
デプロイ
| ホスト | NUXT_CLIENT_IP_HEADER | ストレージのドライバー |
|---|---|---|
| Vercel | x-forwarded-for | Vercel Marketplace の upstash |
| Netlify | x-nf-client-connection-ip | upstash か redis |
| Cloudflare | cf-connecting-ip | upstash。Workers KV は結果整合性なので、カウントが遅れる |
| Node サーバー | プロキシが設定するヘッダー | redis か、単一インスタンスならメモリ |
NUXT_ の変数はすべてホストのダッシュボードで設定します。Nuxt はサーバーの起動時に読むので、キーを変えたら再ビルドではなく再起動が必要です。本番とプレビューのホスト名をすべて Turnstile ウィジェットに追加してください。
チェックを Ikiform に任せる
サーバールートがなければ、お問い合わせページも nuxt generate のサイトの他のページと一緒にプリレンダーできます。method="post" と、エンドポイントの URL を設定した action で、通常のフォームの送信先を Ikiform のエンドポイントにし、server/api/contact.post.ts とコンポーザブルを削除します。
フォームの Spam check を Ikiform に設定します。トークンのない送信には ikiform.com 上で Turnstile を実行してフィールドを送り届ける短い確認ページが表示されるので、CSP に Cloudflare の項目は不要です。Rate limit で訪問者ごとに 10 分あたり 2 回の Strict を選び、Allowed domains にドメインを登録します。Ikiform は捕まえたスパムを、判定理由とともに 30 日間保持します。エンドポイントとキーについては Nuxt の設定ガイドを参照してください。
動作を確認する
- テスト用のキーで
nuxt devを実行し、メッセージを送ります。Submissions に表示されます。プランのないワークスペースでも、localhostからのテストを月 50 件まで受け付けます。 - 1 分以内に 6 通のメッセージを送ります。6 通目では残りの秒数が表示されます。
NUXT_TURNSTILE_SECRET_KEYを常に失敗するシークレットにして再起動します。どの送信でも人間確認のエラーが表示されます。nuxt buildの後、curl -Iで/contactをリクエストします。レスポンスにContent-Security-Policyが含まれています。- ハニーポットを
_gotcha=botと埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
ハマりどころ
| 問題 | 対処 |
|---|---|
| 本番でシークレットが空になる | 本番では実行時に .env が読まれません。NUXT_TURNSTILE_SECRET_KEY などはホストの環境で設定してください。 |
| デプロイやインスタンスごとに制限がリセットされる | ストレージのマウントがまだメモリ上です。$production.nitro.storage が共有ドライバーをマウントし、その認証情報が設定されているか確認してください。 |
| スクリプトがヘッダーを変えて制限をすり抜ける | そのヘッダーを上書きしないホストで、xForwardedFor: true 付きの getRequestIP を読んでいます。clientIp でホスト自身のヘッダーを読んでください。 |
サーバーレンダリング中に window is not defined が出る | Turnstile のコードが onMounted の外で実行されました。window へのアクセスはすべてコンポーザブルのライフサイクルフックの中に置いてください。 |
よくある質問
onMounted でウィジェットを明示的に描画し、トークンを ref に保持して、フィールドと一緒にサーバールートに送信します。ルートは runtimeConfig のシークレットキーと一緒にトークンを siteverify に送り、成功した場合にだけ処理を続けます。
Nitro の useStorage でクライアント IP ごとのリクエストを数えます。本番では Redis か Upstash にマウントして、すべてのインスタンスでカウントを共有します。カウントが上限を超えたら、createError で 429 を投げます。
クライアント自身の X-Forwarded-For をそのまま通すホストで xForwardedFor を true にするのは安全ではありません。最初の値はクライアントが送ったものそのままだからです。代わりに、ホストが上書きするヘッダーを読んでください。
いいえ、無料プランはありません。テストは無料で、支払い前でも localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com