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

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

フォーム送信にレート制限をかける方法

共有ストアで訪問者ごとにフォーム送信のレート制限をかけ、Retry-After 付きの 429 を返し、1 つの IP を共有するオフィスを締め出さないようにします。

Preet SutharIkiform 創業者

すべてのサーバーインスタンスが共有する Redis などのストアで、訪問者の IP ごとに送信数を数え、小さなバーストを許し、上限に達したら Retry-After ヘッダー付きで 429 を返します。メモリ内のカウンターが機能するのは、長時間動き続ける 1 台のサーバーだけで、サーバーレスのホストでは機能しません。上限は実際の人がフォームをどう使うかから決め、多くの人が 1 つの IP を共有するオフィスやイベントのための余裕を残してください。

レート制限ならではの違い

レート制限はスパムと本物のメールを区別しません。止めるのは量です。ループで送信するスクリプト、フォームを繰り返し回るボット、ボタンを連打する人などです。そのため、ボディを読む前に動くので最もコストが低い層であり、攻撃者と忙しいオフィスを区別できないので最も大ざっぱな層でもあります。役に立つか害になるかは、3 つのこと、つまりカウントをどこに置くか、何を単位に数えるか、許容量がどれだけ速く戻るかで決まります。

1 人の訪問者からの連続送信と、なめらかな訪問者ごとの制限送信 1〜310 秒以内: 受付送信 4Retry-After付きの 42912 秒ごと1 件追加で送信可静かな 1 分許容量が全回復バースト待機補充1 人の訪問者からの連続送信と、なめらかな訪問者ごとの制限送信 1〜310 秒以内: 受付送信 4Retry-After 付きの 42912 秒ごと1 件追加で送信可静かな 1 分許容量が全回復バースト待機補充
  1. 訪問者が 10 秒以内に 3 件送信します。すべて受け付けられます。
  2. 次の 1 件は、どれだけ待てばよいかを示す Retry-After ヘッダー付きの 429 で拒否されます。
  3. その後は 12 秒ごとに 1 件ずつ送信できます。
  4. まるまる 1 分間送信がなければ、許容量はすべて戻ります。
これは Ikiform の Standard の数値で、1 分あたり 5 件、10 秒あたり最大 3 件です。なめらかに補充されるので、あと 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 回保存します:

lib/limit.ts
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 に送信し、フォームでプリセットを選んでください。上限を超えた訪問者には、エラーではなく短い「速度を落としてください」ページが表示されます。

contact.html
<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>

Cloudflare Workers では、組み込みのバインディングが最も安価な選択肢です:

wrangler.toml
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"

  [ratelimits.simple]
  limit = 5
  period = 60
src/index.ts
const 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 がカウントします:

nginx.conf
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 件の大量送信ガードが動きます。

プリセット訪問者ごと向いている用途
Standard1 分あたり 5 件、10 秒あたり最大 3 件ほとんどのフォーム
Relaxed1 分あたり 20 件共有 Wi-Fi でのイベント、ログイン後のフォーム
Strict10 分あたり 2 件、5 分ごとに 1 件追加お問い合わせや営業のフォーム
Custom10 秒、1 分、10 分、1 時間、1 日あたり 1〜1,000 件1 人 1 回のエントリー、キオスク端末

Custom で上限を設定しない限り、フォーム全体でも全訪問者合計で 1 分あたり 300 件までです。上限を超えると、ブラウザーからの送信には「速度を落としてください」ページが表示され、fetch には Retry-After 付きの 429 rate_limited が返ります。Ikiform はこのヘッダーを CORS で公開しているので、上のコードから読めます。補充の仕組みはレート制限のドキュメントで説明しています。

サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains と自身の人間確認をスキップします。すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分 5 件という Standard の制限は全訪問者の合計で数えられます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

動作を確認する

  1. フォームを続けて何度か送信します。上限を超えると、Retry-After ヘッダー付きの 429 か、Ikiform の「速度を落としてください」ページが返ります。
  2. Retry-After の秒数だけ待ってから、もう一度送信します。受け付けられます。
  3. Wi-Fi を切ったスマートフォンなど、別のネットワークから送信します。こちらには別の許容量があります。
  4. サーバーが送信を転送する場合は、2 台の端末からすばやく送信し、互いのカウントを共有していないことを確認します。

ハマりどころ

  • 拒否された送信で許容量を消費しないこと。 処理の前に数え、拒否された送信でウィンドウを先延ばしにしないでください。そうしないと、焦った訪問者が自分で自分を締め出してしまいます。
  • ページの読み込みは制限しないこと。 フォームの表示回数ではなく送信を数えてください。そうしないと、再読み込みしただけで送信の枠を使い切ってしまいます。
  • 429 rate_limited 1 人の訪問者が、フォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件です。Retry-After の時間だけ待つか、1 つの共有 IP から送信されるフォームには Relaxed か Custom を選んでください。
  • IPv6。 1 人が多数の IPv6 アドレスを持てます。厳密な上限にするなら、アドレス全体ではなく /64 のプレフィックスで数えてください。

よくある質問

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