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

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

静的サイトのお問い合わせフォームでスパムを防ぐ方法

シークレットを保持できず送信数も数えられない静的サイトのフォームでも、ハニーポットのパーシャル、安全なスクリプト、サーバー側のチェックでスパムを防ぎます。

Preet SutharIkiform 創業者

静的サイトにはサーバーがないので、ページ上の何もシークレットキーを保持できず、CAPTCHA のトークンを検証したり、訪問者ごとの送信数を数えたりできません。これらのチェックは、ホストの関数か、フォームの送信先のフォームバックエンドなど、別の場所で実行する必要があります。どちらの場合も、フォームにはアクセシブルなハニーポットを置き、JavaScript なしでもフォームが動くようにしてください。

静的サイトならではの違い

静的サイトでは、デプロイしたものはすべて公開されます。HTML や JavaScript にあるものはボットに読まれ、迂回されるので、クライアント側のスパムチェックは強制力のない助言にすぎません。最も重要な 3 つのチェックは、どれもサーバーが必要です:

  • captcha トークンの検証 には、シークレットキーが必要で、公開ファイルには置けません。
  • 訪問者ごとの送信数のカウント には、リクエストをまたいで残るストレージが必要です。
  • ハニーポットの読み取り には、送信が届いた後にボットの手の届かない場所で動くコードが必要です。

さらに、静的ホストはページへの送信を受け付けません。Vercel や GitHub Pages は静的ページへのフォームの POST に 405 Method Not Allowed を返すので、スパム以前に、フォームにはエンドポイントが必要です。

静的サイトのフォームをつなぐ 3 つの方法と、それぞれでスパムチェックが動く場所ページだけホストの関数を使うフォームバックエンドを使う静的ページ自分自身に送信ホストコードは動かない405 か消失静的ページ関数シークレット、ハニーポット、トークン確認メールかストレージ静的ページフォームバックエンドハニーポット、スコア、制限、Turnstile受信トレイと Webhook静的サイトのフォームをつなぐ 3 つの方法と、それぞれでスパムチェックが動く場所ページだけホストの関数を使うフォームバックエンドを使う静的ページ自分自身に送信ホストコードは動かない405 か消失静的ページ関数シークレット、ハニーポット、トークン確認メールかストレージ静的ページフォームバックエンドハニーポット、スコア、制限、Turnstile受信トレイと Webhook
  1. 自分自身に送信する静的ページは、コードを実行しないホストに届くので、送信は 405 で失敗するか、どこにも届きません。
  2. ホストの関数を使う場合、ページは関数に送信し、関数がシークレットを持ち、ハニーポットを読み、トークンを検証してからメッセージを送ります。
  3. フォームバックエンドを使う場合、ページはバックエンドに直接送信し、バックエンドがハニーポット、スコア判定、レート制限、人間確認を実行してからメールで通知します。
動く 2 つの方法のどちらでも、チェックは送信がブラウザーを離れた後に実行されます。ボットが迂回できないのはそこだけです。

選択肢

選択肢スパムチェックトレードオフ
Netlify Formsすべての送信に Akismet。追加すれば netlify-honeypot フィールドと reCAPTCHA v2 もNetlify 専用。ホストを移るとフォームを作り直すことになる。Netlify Forms の代替を参照
ホスト上の関数書いたものすべて。ハニーポット、トークンの確認、ストアを使うレート制限自分で書いて保守する必要があり、背後にメールやストレージのサービスが必要
Ikiform などのフォームバックエンドハニーポット、スコア判定、レート制限、許可ドメイン、人間確認をコードなしで別のサービスで、送信件数に応じた料金

再利用できるパーシャルとしてのフォーム

どのエンドポイントを選んでも、フォームはパーシャルやインクルードとして 1 回だけ書き、すべてのページで同じハニーポットを使います。以下の版は Ikiform に送信します。関数を使う場合は、action を関数のパスに変え、_redirect を削除してください。

<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
  <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></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>
  <input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}" />
  <p role="status"></p>
  <button>Send message</button>
</form>
assets/contact.css
<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
  • ハニーポットは画面外に置き、タブ順から外し、スクリーンリーダーからも隠します。各属性の説明はハニーポットのガイド にあります。
  • _redirect は訪問者をサンクスページに移動させます。Ikiform がこれに従うのは、フォームの Allowed domains(許可ドメイン)にある https: のアドレスの場合だけです。
  • セットアップ、ショートコード、GitHub Pages については Hugo、Jekyll、Eleventy のガイドで説明しています。

壊さずに機能を上乗せする

スクリプトで訪問者をページにとどめることはできますが、スクリプトがフォームを動かす唯一の手段になってはいけません。このスクリプトは fetch で送信し、2 つの場合に通常のブラウザー送信にフォールバックします。ネットワーク呼び出しが失敗したときと、Ikiform の人間確認が challenge_required でトークンを求めたときです。通常の送信では代わりに Ikiform の確認ページが表示されるので、スクリプトを変えずに確認をオンにできます。

contact-form.html
<script>
  document.querySelectorAll("form.contact").forEach((form) => {
    const status = form.querySelector("[role=status]");
    const button = form.querySelector("button");

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      try {
        const response = await fetch(form.action, {
          method: "POST",
          headers: { Accept: "application/json" },
          body: new FormData(form),
        });
        const result = await response.json();
        if (result.ok) {
          form.replaceChildren("Thanks. We'll reply by email.");
          return;
        }
        if (result.error.code === "challenge_required") {
          form.submit();
          return;
        }
        const wait = response.headers.get("Retry-After");
        if (response.status === 429 && wait) {
          status.textContent = `Too many messages. Try again in ${wait} seconds.`;
        } else {
          status.textContent = result.error.message;
        }
      } catch {
        form.submit();
        return;
      }
      button.disabled = false;
    });
  });
</script>
  • form.submit() は submit イベントを再び発火させずにフォームを送信するので、フォールバックがループすることはありません。
  • 429 のときは、Ikiform がブラウザーのコードに公開している Retry-After から待ち時間を表示します。
  • 成功の一文がフォームを置き換えるので、うっかり 2 回送信されることはありません。

ホストの関数を書く場合

Netlify、Cloudflare Pages、Vercel はどれも、静的ファイルの隣で小さな関数を実行できます。関数は自分のバックエンドなので、スパムチェックも関数が担います。この Netlify Function は、ハニーポットを読み、リンクだらけのメッセージを捨て、Turnstile のトークンを検証し、Resend 経由でメールを送ります。捕まえた送信にも、本物と同じサンクスページへのリダイレクトを返します。

netlify/functions/contact.mts
import type { Config, Context } from "@netlify/functions";

const LINK = /https?:\/\/|www\./gi;

export default async (req: Request, context: Context) => {
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const form = await req.formData();
  const email = String(form.get("email") ?? "");
  const message = String(form.get("message") ?? "");
  const thanks = Response.redirect(new URL("/thanks/", req.url), 303);

  if (String(form.get("_gotcha") ?? "").trim()) return thanks;
  if ((message.match(LINK)?.length ?? 0) >= 3) return thanks;

  const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    body: new URLSearchParams({
      secret: process.env.TURNSTILE_SECRET_KEY ?? "",
      response: String(form.get("cf-turnstile-response") ?? ""),
      remoteip: context.ip,
    }),
  });
  const verdict = (await check.json()) as { success: boolean };
  if (!verdict.success) return Response.redirect(new URL("/contact/?error=check", req.url), 303);

  await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      from: "Contact form <form@example.com>",
      to: "you@example.com",
      reply_to: email,
      subject: "New message from the website",
      text: message,
    }),
  });
  return thanks;
};

export const config: Config = { path: "/api/contact" };
  • context.ip は訪問者の IP で、siteverify の remoteip として使えます。
  • TURNSTILE_SECRET_KEY と RESEND_API_KEY はサイトの環境変数に設定し、リポジトリには絶対に入れないでください。
  • 捕まえた送信は保存せずに捨てるので、誤って捕まった本物のメッセージは失われます。少なくともログには残してください。
  • 関数は呼び出しの間で記憶を持たないので、レート制限はありません。Redis で追加する方法はレート制限のガイドにあります。

静的なフォームに対して Ikiform が行うこと

フォームが Ikiform に直接送信する場合、サーバー側の処理は Ikiform で行われます。ハニーポットは _gotcha で、内容のスコア判定と訪問者ごとのレート制限はすべての送信で実行され、Allowed domains は他のサイトからの送信をスパムとして扱います。Spam check を Ikiform にすると、キーなしで Cloudflare Turnstile を使えます。プレーンな HTML の送信では確認ページで実行され、上のスクリプトはそのページにフォールバックします。スパムと判定された送信にも本物と同じ成功の応答を返すので、ボットは何も学べません。ステータス spam で保存され、通知メールも Webhook も送らず、使用量にもカウントされず、30 日後に削除されます。毎週月曜日、スパムをブロックしたワークスペースには、合計件数、最も多かったフォーム、主な理由をまとめたメールが 1 通届きます。

フォームを公開しているすべてのホストを Allowed domains に登録します。自分のドメインに加え、テスト中は username.github.io のようなホスト自身のアドレスも登録してください。Netlify のデプロイプレビューや Vercel のプレビュー URL はメインサイトのアドレスのサブドメインではないので、プレビューをテストするときはそのホストを正確に追加します。

動作を確認する

  1. JavaScript をオフにして送信します。フォームは送信され、サンクスページに移動します。
  2. JavaScript をオンにして送信します。フォームがお礼の一文に置き換わります。
  3. ハニーポットを _gotcha=bot と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
  4. Spam check を Ikiform にし、スクリプトをオンにして送信します。ページは通常の送信にフォールバックし、確認ページを表示します。
  5. 何も届きませんか?ダッシュボードのステータスフィルターを Spam に切り替えてください。スパムにも本物の送信データと同じ成功の応答が返ります。

ハマりどころ

  • 403 origin_not_allowed が返る、またはすべての送信がスパムになる。 ページのドメインがフォームの Allowed domains にありません。ドメインと、ローカルテスト用の localhost を追加してください。登録したドメインのサブドメインは許可されます。
  • HTML の圧縮。 ビルド処理によっては、空や未使用とみなした属性を削除します。ビルド後のページで、ハニーポットに aria-hidden と tabindex が残っているか確認してください。
  • キャッシュされたサンクスページ。 サンクスページはスパムにも本物の送信にも同じ URL であるべきなので、送信が本物であることを前提にした内容を載せてはいけません。
  • リポジトリ内のキー。 公開されている静的サイトのリポジトリにコミットしたシークレットは公開されています。一度でもコミットしたならローテーションしてください。

よくある質問

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