お問い合わせフォームのスパムを止める3 分で読めます
Turnstile を使った SvelteKit フォームのスパム対策
SvelteKit のフォームをスパムから守る方法。getClientAddress を使うフォーム Action、IP ごとの制限、use:enhance の裏の Turnstile、それを許可する CSP。
チェックはフォーム Action で行います。getClientAddress() で制限し、siteverify で Turnstile のトークンを検証してから、ハニーポットと一緒にフィールドを転送します。トークンは 1 回しか使えないので、ウィジェットは Svelte のアクションで描画し、use:enhance のコールバックでリセットします。sveltekit() プラグインの csp オプションで challenges.cloudflare.com を許可し、adapter-node では本当の IP がどのプロキシヘッダーにあるかを SvelteKit に伝えます。
SvelteKit ならではの違い
SvelteKit は訪問者の IP を直接渡してくれます。リクエストイベントの getClientAddress() です。この値の出どころはアダプターによって異なり、adapter-node のデフォルトはプロキシの背後では誤った値になります。そこさえ正しくすれば、残りのチェックはフォーム Action にきれいに収まります。
- フォーム Action がリクエストイベントにクライアントのアドレスを尋ねます。
- SvelteKit は、デプロイに使うアダプターに答えを委ねます。
- adapter-node は、ADDRESS_HEADER と XFF_DEPTH を設定しない限りソケットのリモートアドレスを使います。
- Vercel、Netlify、Cloudflare のアダプターはプラットフォームが渡すアドレスを読むので、設定は不要です。
SvelteKit のもう 1 つの細かい点は use:enhance です。ページを再読み込みせずに送信するので利用者には快適ですが、Turnstile が自動で再描画されることはありません。応答のたびに手動でウィジェットをリセットする必要があります。
プロジェクトの準備
src/
env.ts
lib/
turnstile.ts
server/
spam.ts
routes/
contact/
+page.server.ts
+page.svelte
vite.config.ts
.envPUBLIC_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 { 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を含むどのホストでも通ります。
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 を描画します。
サーバー専用のヘルパー
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
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_SECRET_KEY はサーバー専用の環境変数に置いてください。Svelte のアクションとしてのウィジェット
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 は要素に保持するので、ページはその特定のウィジェットをリセットできます。
ページ
<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=1SvelteKit のドキュメントは、ヘッダーは簡単に偽装でき、X-Forwarded-For の一番左のアドレスを読むのが典型的な間違いだと警告しています。XFF_DEPTH は信頼するプロキシの数を右から数えるので、運用しているプロキシの数に設定し、プロキシを経由せずにアプリへ到達できないようにしてください。SvelteKit 3 では adapter-node の ORIGIN 変数が廃止されました。プロキシが元のホストを渡さない場合は、sveltekit() のオプションで paths.origin を設定し、SvelteKit 自身のフォームチェックが公開 URL を把握できるようにします。
| アダプター | クライアント IP | シークレット |
|---|---|---|
adapter-node | ADDRESS_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 の設定ガイドを参照してください。
動作を確認する
- テスト用のキーで
vite devを実行し、メッセージを送ります。Submissions に表示されます。プランのないワークスペースでも、localhostからのテストを月 50 件まで受け付けます。 - 再読み込みせずに 2 通目のメッセージを送ります。成功すれば、ウィジェットのリセットが機能しています。
- 1 分以内に 6 通のメッセージを送ります。6 通目では、あと何秒待てばよいかが表示されます。
- プロキシの背後で、2 つのネットワークから
getClientAddress()をログに出します。プロキシのアドレスではなく、2 つの異なるアドレスが表示されます。 - ハニーポットを
_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 がありません。 |
よくある質問
フォーム Action、load 関数、フックで、リクエストイベントの getClientAddress() を呼びます。値はアダプターが渡します。プロキシの背後の adapter-node では、ADDRESS_HEADER と XFF_DEPTH を設定しないとプロキシのアドレスが返ります。
use:enhance はページを再読み込みしないので、ウィジェットが使用済みのトークンを保持したままになります。応答のたびに enhance のコールバックでウィジェットをリセットしてください。
はい。csp.directives の script-src と frame-src に https://challenges.cloudflare.com を追加します。SvelteKit 3 では、これを vite.config.ts の sveltekit() プラグインのオプションで渡します。mode を auto にすると、SvelteKit が自身のスクリプトに nonce かハッシュを付けます。
いいえ、無料プランはありません。テストは無料で、支払い前でも localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com