お問い合わせフォームのスパムを止める2 分で読めます
フォーム送信にレート制限をかける方法
共有ストアで訪問者ごとにフォーム送信のレート制限をかけ、Retry-After 付きの 429 を返し、1 つの IP を共有するオフィスを締め出さないようにします。
すべてのサーバーインスタンスが共有する Redis などのストアで、訪問者の IP ごとに送信数を数え、小さなバーストを許し、上限に達したら Retry-After ヘッダー付きで 429 を返します。メモリ内のカウンターが機能するのは、長時間動き続ける 1 台のサーバーだけで、サーバーレスのホストでは機能しません。上限は実際の人がフォームをどう使うかから決め、多くの人が 1 つの IP を共有するオフィスやイベントのための余裕を残してください。
レート制限ならではの違い
レート制限はスパムと本物のメールを区別しません。止めるのは量です。ループで送信するスクリプト、フォームを繰り返し回るボット、ボタンを連打する人などです。そのため、ボディを読む前に動くので最もコストが低い層であり、攻撃者と忙しいオフィスを区別できないので最も大ざっぱな層でもあります。役に立つか害になるかは、3 つのこと、つまりカウントをどこに置くか、何を単位に数えるか、許容量がどれだけ速く戻るかで決まります。
- 訪問者が 10 秒以内に 3 件送信します。すべて受け付けられます。
- 次の 1 件は、どれだけ待てばよいかを示す Retry-After ヘッダー付きの 429 で拒否されます。
- その後は 12 秒ごとに 1 件ずつ送信できます。
- まるまる 1 分間送信がなければ、許容量はすべて戻ります。
カウントを置く場所
| ストア | 動く環境 | 注意点 |
|---|---|---|
メモリ内の Map | 長時間動く 1 台のサーバー | 再起動で消える。サーバーレスのインスタンスはそれぞれ別の Map を持つので、実際の上限はわからない |
| Upstash などの Redis | サーバーレスやエッジを含むすべて | 送信ごとにネットワーク呼び出しが 1 回。サーバーに近いリージョンを選ぶ |
| Cloudflare Workers のレート制限バインディング | Workers | おおよその値で、Cloudflare のロケーションごとにカウント。期間は 10 秒か 60 秒のみ |
nginx の limit_req などのリバースプロキシ | 自前のプロキシの背後にあるサーバー | プロキシごとにカウント。実際のクライアント IP が必要 |
アルゴリズムはストアほど重要ではありませんが、訪問者の体感を左右します。固定ウィンドウは分の区切りでリセットされるので、スクリプトはある 1 分の終わりに許容量をすべて使い、次の 1 分の始めにまた使い切れます。スライディングウィンドウは前のウィンドウを加味してこの境目をならします。トークンバケットや、Ikiform が使う同様のアルゴリズムは 1 件ずつ補充するので、入力ミスをした人は数秒後に再送でき、バーストを許すこともありません。お問い合わせフォームなら、数値が人の使い方に合っていればどれでも機能します。
以下の例では Upstash の @upstash/ratelimit を使います。どのフレームワークでもサーバーレスのホストでも同じように動くからです。UPSTASH_REDIS_REST_URL と UPSTASH_REDIS_REST_TOKEN を設定し、ヘルパーを 1 回保存します:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const contact = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "60 s"),
prefix: "contact",
});
export async function allow(ip: string | null | undefined) {
const { success, reset } = await contact.limit(ip || "no-ip");
return { ok: success, retryAfter: Math.max(1, Math.ceil((reset - Date.now()) / 1000)) };
}reset はミリ秒単位の Unix 時刻なので、ヘルパーは Retry-After 用にそれを整数の秒に変換します。IP のない送信は 1 つのバケットを共有するので、ヘッダーがないことが素通りの抜け道になることはありません。
フレームワークに適用する
プレーンな HTML フォームには何かを数えるサーバーがありません。Ikiform に送信し、フォームでプリセットを選んでください。上限を超えた訪問者には、エラーではなく短い「速度を落としてください」ページが表示されます。
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button>Send</button>
</form>サーバーアクションの冒頭で、他の処理より先に上限を確認します。
"use server";
import { headers } from "next/headers";
import { allow } from "@/lib/limit";
export async function sendMessage(_previous: { message: string }, formData: FormData) {
const forwarded = (await headers()).get("x-forwarded-for");
const limit = await allow(forwarded?.split(",")[0]?.trim());
if (!limit.ok) return { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
await saveMessage(Object.fromEntries(formData));
return { message: "Thanks. We'll reply by email." };
}オンデマンドのページでは、context.clientAddress で訪問者の IP を取得できます。TOO_MANY_REQUESTS は 429 に対応します。
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { allow } from "../lib/limit";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({ email: z.email(), message: z.string().min(1) }),
handler: async (input, context) => {
const limit = await allow(context.clientAddress);
if (!limit.ok) {
throw new ActionError({ code: "TOO_MANY_REQUESTS", message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
}
await saveMessage(input);
return { sent: true };
},
}),
};サーバー関数からステータスと Retry-After を設定し、fetch の呼び出し側が読めるようにします。
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader, setResponseHeaders, setResponseStatus } from "@tanstack/react-start/server";
import { allow } from "../lib/limit";
export const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return { email: String(data.get("email") ?? ""), message: String(data.get("message") ?? "") };
})
.handler(async ({ data }) => {
const limit = await allow(getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim());
if (!limit.ok) {
setResponseStatus(429);
setResponseHeaders(new Headers({ "Retry-After": String(limit.retryAfter) }));
return { ok: false, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
}
await saveMessage(data);
return { ok: true, message: "Thanks. We'll reply by email." };
});data() を使うと、Action はフォーム用のメッセージと一緒に、ヘッダー付きの 429 を返せます。
import { data } from "react-router";
import { allow } from "../lib/limit";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const limit = await allow(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim());
if (!limit.ok) {
return data(
{ message: `Too many messages. Try again in ${limit.retryAfter} seconds.` },
{ status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
);
}
await saveMessage(Object.fromEntries(await request.formData()));
return { message: "Thanks. We'll reply by email." };
}getClientAddress() はアダプターから IP を読みます。fail(429) はメッセージ付きでフォームをページに残します。ヘルパーは src/lib/limit.ts として保存します。SvelteKit 3 では package.json の #lib エイリアス経由でインポートします。
import { fail } from "@sveltejs/kit";
import { allow } from "#lib/limit.js";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, getClientAddress }) => {
const limit = await allow(getClientAddress());
if (!limit.ok) return fail(429, { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
await saveMessage(Object.fromEntries(await request.formData()));
return { sent: true };
},
} satisfies Actions;getRequestIP に xForwardedFor を付けると、プロキシの背後でもクライアントの IP を読めます。ヘルパーを server/utils に保存すると自動でインポートされます。
export default defineEventHandler(async (event) => {
const limit = await allow(getRequestIP(event, { xForwardedFor: true }));
if (!limit.ok) {
setResponseHeader(event, "Retry-After", limit.retryAfter);
throw createError({ status: 429, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
}
await saveMessage(await readBody(event));
return { sent: true };
});シングルページアプリは上限を強制できませんが、尊重すべきです。もう一度ボタンを押させるのではなく、Retry-After を読んでカウントダウンを表示してください。429 quota_exceeded には Retry-After がないので、エラーメッセージの処理に回ります。
const button = form.querySelector("button");
const status = form.querySelector("[role=status]");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const retryAfter = response.headers.get("Retry-After");
if (response.status === 429 && retryAfter) {
let wait = Number(retryAfter);
button.disabled = true;
const timer = setInterval(() => {
status.textContent = `Too many messages. Try again in ${wait} seconds.`;
wait -= 1;
if (wait < 0) {
clearInterval(timer);
button.disabled = false;
status.textContent = "";
}
}, 1000);
return;
}
const result = await response.json();
status.textContent = result.ok ? "Thanks. We'll reply by email." : result.error.message;
});Vue コンポーネントでも処理は同じです。Retry-After を読み、ボタンを無効にし、ref でカウントダウンします。
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const wait = ref(0);
const notice = ref("");
let timer: ReturnType<typeof setInterval> | undefined;
function countDown(seconds: number) {
wait.value = seconds;
clearInterval(timer);
timer = setInterval(() => {
wait.value -= 1;
if (wait.value <= 0) clearInterval(timer);
}, 1000);
}
async function send(event: SubmitEvent) {
notice.value = "";
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const retryAfter = Number(response.headers.get("Retry-After"));
if (response.status === 429 && retryAfter > 0) {
countDown(retryAfter);
return;
}
const result = await response.json();
notice.value = result.ok ? "Thanks. We'll reply by email." : result.error.message;
}
onBeforeUnmount(() => clearInterval(timer));
</script>
<template>
<form :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required />
<p role="status">{{ wait > 0 ? `Too many messages. Try again in ${wait} seconds.` : notice }}</p>
<button :disabled="wait > 0">Send</button>
</form>
</template>Cloudflare Workers では、組み込みのバインディングが最も安価な選択肢です:
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"
[ratelimits.simple]
limit = 5
period = 60const ip = request.headers.get("CF-Connecting-IP") ?? "no-ip";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) return new Response("Too many messages", { status: 429, headers: { "Retry-After": "60" } });Cloudflare のドキュメントによると、このバインディングはおおよその値で、ロケーションごとに別々にカウントされるので、分散した攻撃では上限を超えることがあります。ロケーションをまたいだ正確な上限には、Cloudflare は Durable Object を推奨しています。自前の nginx の背後なら、アプリが送信を受け取る前に limit_req がカウントします:
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/m;
server {
location = /api/contact {
limit_req zone=contact burst=3 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3000;
}
}何を単位に数えるか
- IP とフォームの組み合わせ。 通常の選択です。1 人の訪問者がこのフォームを大量送信で埋めることはできず、サイトの別のフォームを使っても同じ許容量は消費されません。
- 多くの人と 1 つの IP。 オフィス、学校、携帯キャリア、カンファレンスの Wi-Fi では、多くの人が 1 つのアドレスの背後にいます。IP ごとの上限はそうした人たちに十分なほど緩くするか、イベントの日には引き上げてください。
- 実際のクライアント IP。 プロキシや CDN の背後では、ホストが設定するヘッダーを読み、それだけを信頼してください。生の
X-Forwarded-Forの値はクライアントが設定できます。 - 自分のサーバーの IP。 サーバーが送信を別のサービスに転送すると、転送先ではすべての訪問者がサーバーの IP を共有します。転送する前に、自分の側で訪問者の IP ごとに制限してください。
Ikiform のレート制限
Ikiform はボディを読む前に、訪問者の IP とフォームごとにすべての送信を正確に数え、固定ウィンドウではなくなめらかに補充します。各フォームは Rate limit でプリセットを選びます。それらより前に、全フォーム合計で IP ごとに 1 分あたり 600 件の大量送信ガードが動きます。
| プリセット | 訪問者ごと | 向いている用途 |
|---|---|---|
| Standard | 1 分あたり 5 件、10 秒あたり最大 3 件 | ほとんどのフォーム |
| Relaxed | 1 分あたり 20 件 | 共有 Wi-Fi でのイベント、ログイン後のフォーム |
| Strict | 10 分あたり 2 件、5 分ごとに 1 件追加 | お問い合わせや営業のフォーム |
| Custom | 10 秒、1 分、10 分、1 時間、1 日あたり 1〜1,000 件 | 1 人 1 回のエントリー、キオスク端末 |
Custom で上限を設定しない限り、フォーム全体でも全訪問者合計で 1 分あたり 300 件までです。上限を超えると、ブラウザーからの送信には「速度を落としてください」ページが表示され、fetch には Retry-After 付きの 429 rate_limited が返ります。Ikiform はこのヘッダーを CORS で公開しているので、上のコードから読めます。補充の仕組みはレート制限のドキュメントで説明しています。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。動作を確認する
- フォームを続けて何度か送信します。上限を超えると、
Retry-Afterヘッダー付きの429か、Ikiform の「速度を落としてください」ページが返ります。 Retry-Afterの秒数だけ待ってから、もう一度送信します。受け付けられます。- Wi-Fi を切ったスマートフォンなど、別のネットワークから送信します。こちらには別の許容量があります。
- サーバーが送信を転送する場合は、2 台の端末からすばやく送信し、互いのカウントを共有していないことを確認します。
ハマりどころ
- 拒否された送信で許容量を消費しないこと。 処理の前に数え、拒否された送信でウィンドウを先延ばしにしないでください。そうしないと、焦った訪問者が自分で自分を締め出してしまいます。
- ページの読み込みは制限しないこと。 フォームの表示回数ではなく送信を数えてください。そうしないと、再読み込みしただけで送信の枠を使い切ってしまいます。
429 rate_limited1 人の訪問者が、フォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件です。Retry-Afterの時間だけ待つか、1 つの共有 IP から送信されるフォームには Relaxed か Custom を選んでください。- IPv6。 1 人が多数の IPv6 アドレスを持てます。厳密な上限にするなら、アドレス全体ではなく
/64のプレフィックスで数えてください。
よくある質問
お問い合わせフォームなら、訪問者ごとに数件で十分です。Ikiform の Strict プリセットは 10 分あたり 2 件です。共有ネットワークから使われる登録フォームやイベントのフォームには、1 分あたり 20 件程度が必要です。
確実には使えません。サーバーレスのインスタンスはそれぞれ独自のメモリを持ち、生まれては消えるので、カウントがランダムにリセットされます。Redis などの共有ストアを使ってください。
Retry-After ヘッダーから取った待ち時間を示すシンプルなメッセージを表示し、それまで送信ボタンを無効にします。入力された内容は消さないでください。
はい。どのフォームも最初は Standard プリセット(訪問者ごとに 1 分あたり 5 件)で、フォームごとに Relaxed、Strict、Custom に切り替えられます。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com