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

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

CAPTCHA なしでフォームスパムを防ぐ方法

CAPTCHA をまったく使わずにフォームスパムの大半を防ぐ方法。ハニーポット、署名付きの表示時刻、内容のスコア、レート制限を単独ではなく合算して使います。

Preet SutharIkiform 創業者

目に見えない 4 つのチェックを組み合わせ、見つかったものを合計します。ハニーポット、フォームが開かれていた時間を示す署名付きのタイムスタンプ、リンクやスパム的な語句のスコア、訪問者ごとのレート制限です。合計がしきい値を超えたときだけスパムとして扱えば、弱いシグナル 1 つで本物の人がブロックされることはありません。捕まえたものは残しておき、スパムフォルダーを見てまだボットが通り抜けているときだけ、後から CAPTCHA を追加します。

CAPTCHA がない場合の違い

CAPTCHA は強力なイエスかノーを 1 つ返します。CAPTCHA がなければ、代わりに弱いシグナルが複数あるだけで、どれも単独ではかわすのも、うっかり引っかかるのも簡単です。速い送信はボットかもしれませんし、用意したメッセージを貼り付けた人かもしれません。リンクはスパムかもしれませんし、ポートフォリオかもしれません。コツはスコアにすることです。各シグナルに重みを付けて合計し、しきい値を超えたときだけスパムとみなします。唯一の強いシグナルである、埋められたハニーポットには、単独でしきい値を超えるだけの重みを与えます。

3 種類の送信で、目に見えないシグナルが合算されて判定になる様子スクリプトのボット貼り付けた SEO の売り込みリンクが 3 つある本物のメッセージハニーポットが埋まっている+50.4 秒で送信+38: スパム名前にリンク+3"seo services"+25: スパムリンク 3 つ+2他は何もなし2: 受信3 種類の送信で、目に見えないシグナルが合算されて判定になる様子スクリプトのボット貼り付けた SEO の売り込みリンクが 3 つある本物のメッセージハニーポットが埋まっている+50.4 秒で送信+38: スパム名前にリンク+3"seo services"+25: スパムリンク 3 つ+2他は何もなし2: 受信
  1. スクリプトのボットはハニーポットを埋めて 5、1 秒未満で送信して 3 が加わります。合計 8 でスパムです。
  2. 貼り付けられた SEO の売り込みは、名前フィールドのリンクで 3、既知のスパム語句で 2 です。合計 5 で、しきい値の 5 に達します。
  3. リンクが 3 つある本物のメッセージはスコア 2 だけなので、受信されます。
重みは Ikiform のもので、しきい値は 5 です。弱いシグナルが 2 つ重なると超えますが、1 つでは超えません。

署名付きの表示時刻

人がフォームを埋めるには数秒かかります。タイムトラップはその間隔を測ります。弱い方法は Date.now() を hidden フィールドに入れるもので、どんなボットでも書き換えられます。強い方法は、サーバーでシークレットを使って表示時刻に署名するもので、ボットはページを実際より前に開いたと偽れません。このファイルは Web Crypto があればどこでも動きます。Node 20 以降、Deno、Bun、Workers、あらゆるサーバーレスのランタイムです。

lib/render-token.ts
const encoder = new TextEncoder();

async function key(secret: string) {
  return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
    "sign",
    "verify",
  ]);
}

function toHex(bytes: ArrayBuffer) {
  return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

function fromHex(hex: string) {
  return new Uint8Array((hex.match(/../g) ?? []).map((pair) => Number.parseInt(pair, 16)));
}

export async function signRender(secret: string, now = Date.now()) {
  const signature = await crypto.subtle.sign("HMAC", await key(secret), encoder.encode(`render.${now}`));
  return `${now}.${toHex(signature)}`;
}

export type RenderCheck = "ok" | "too_fast" | "invalid";

export async function checkRender(secret: string, token: string, minSeconds = 3, now = Date.now()): Promise<RenderCheck> {
  const [time = "", signature = ""] = token.split(".");
  if (!/^\d{13}$/.test(time) || !/^[0-9a-f]{64}$/.test(signature)) return "invalid";
  const valid = await crypto.subtle.verify("HMAC", await key(secret), fromHex(signature), encoder.encode(`render.${time}`));
  const age = now - Number(time);
  if (!valid || age < 0 || age > 86_400_000) return "invalid";
  return age < minSeconds * 1000 ? "too_fast" : "ok";
}

署名はページの表示時に行うので、ページは静的 HTML としてキャッシュせず、リクエストごとにレンダリングする必要があります。Astro の場合:

src/pages/contact.astro
---
import { signRender } from "../lib/render-token";

export const prerender = false;

const rendered = await signRender(import.meta.env.RENDER_SECRET);
---

<form method="POST" action="/api/contact">
  <input type="hidden" name="rendered" value={rendered} />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <label for="name">Name</label>
  <input id="name" name="name" required />
  <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>
  • お問い合わせフォームなら最低 3 秒が安全です。用意したメッセージを貼り付ける人でも 2 秒はかかることがあります。
  • 1 日より古いトークンは拒否します。そうしないと、ボットがページを 1 回読み込んで同じトークンを何週間も使い回せます。
  • トークンがない、または偽造されているのはシグナルとして扱い、証拠とはみなしません。キャッシュされたページや古いタブでも起こりうるからです。

送信にスコアを付ける

シグナルを 1 つの関数にまとめます。リンクや語句のチェックは、ハニーポットや時間計測では捕まえられないもの、つまり人が入力したり貼り付けたりしたメッセージを捕まえます。語句のリストは短く具体的にしてください。free のような広い単語は本物の顧客まで引っかけます。

lib/score.ts
const WEIGHTS = { honeypot: 5, too_fast: 3, invalid_render: 3, link_in_name: 3, many_links: 2, phrase: 2 };
const THRESHOLD = 5;
const LINK = /https?:\/\/|www\./gi;
const PHRASES = /\b(seo services|guest post|backlinks|first page of google|crypto investment)\b/i;

type Reason = keyof typeof WEIGHTS;

export function scoreContact(input: {
  name: string;
  message: string;
  trap: string;
  render: "ok" | "too_fast" | "invalid";
}) {
  const reasons: Reason[] = [];
  if (input.trap.trim()) reasons.push("honeypot");
  if (input.render === "too_fast") reasons.push("too_fast");
  if (input.render === "invalid") reasons.push("invalid_render");
  if (input.name.match(LINK)) reasons.push("link_in_name");
  if ((`${input.name} ${input.message}`.match(LINK)?.length ?? 0) >= 3) reasons.push("many_links");
  if (PHRASES.test(`${input.name} ${input.message}`)) reasons.push("phrase");
  const score = reasons.reduce((total, reason) => total + WEIGHTS[reason], 0);
  return { score, reasons, spam: score >= THRESHOLD };
}
src/pages/api/contact.ts
import type { APIRoute } from "astro";
import { checkRender } from "../../lib/render-token";
import { scoreContact } from "../../lib/score";

export const POST: APIRoute = async ({ request, redirect }) => {
  const form = await request.formData();
  const render = await checkRender(import.meta.env.RENDER_SECRET, String(form.get("rendered") ?? ""));
  const verdict = scoreContact({
    name: String(form.get("name") ?? ""),
    message: String(form.get("message") ?? ""),
    trap: String(form.get("_gotcha") ?? ""),
    render,
  });
  await saveMessage(Object.fromEntries(form), verdict.spam ? "spam" : "received", verdict.reasons);
  return redirect("/thanks", 303);
};

ハンドラーはスパムを捨てずにすべてをステータス付きで保存し、どちらにも同じリダイレクトで応答します。スパムは後で確認でき、ボットはどのシグナルで捕まったのかを知ることができません。このルートの手前に訪問者ごとのレート制限 を置けば、どの処理も走る前に大量送信を止められます。

Ikiform は CAPTCHA なしでこれらを実行します

Ikiform のフォームはすべて、設定なしでハニーポット、時間計測、内容のスコア、レート制限を実行し、Spam check は最初 Off です。ハニーポットは _gotcha です。内容のスコアでは、リンク、既知のスパム語句 22 個、使い捨てメールのドメイン、24 時間以内の同一内容の繰り返しを確認します。レート制限は最初、訪問者ごとに 1 分あたり 5 件です。スコアが 5 以上の送信はスパムです。

シグナル重み
ハニーポットが埋まっている+5
署名付きの表示から 2 秒未満で送信+3
表示時刻が偽造されているか 24 時間より古い+3
名前や件名のフィールドにリンクがある+3
24 時間以内に同一のフィールド+3
リンクが 3 つ以上+2
既知のスパム語句+2
使い捨てメールのドメイン+2

Ikiform の署名付き表示時刻は、フォームの公開スキーマから取得します。shadcn/ui コンポーネントは自動で送ります。自分でレンダリングするフォームでは、@ikiform/sdk の getSchema() で取得し、_ikiform_ts として送り返せます:

contact.js
import { getSchema, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const form = document.querySelector("form");
const schema = await getSchema(FORM_ID);
const rendered = schema.ok ? schema.schema.ts : null;

form?.addEventListener("submit", async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  if (rendered) data.set("_ikiform_ts", rendered);
  const result = await submit(FORM_ID, data);
  form.replaceWith(result.ok ? "Thanks, we'll be in touch." : result.error.message);
});

プレーンな HTML フォームは表示時刻を送りませんが、それで減点されることはないので、なくても損はありません。スパムと判定された送信にも本物と同じ成功の応答を返すので、ボットは何も学べません。ステータス spam で保存され、通知メールも Webhook も送らず、使用量にもカウントされず、30 日後に削除されます。スパムをブロックしたワークスペースには、毎週月曜日に合計件数、件数の多いフォーム、主な理由をまとめたメールが 1 通届きます。詳しくはスキーマエンドポイントとスパムのドキュメントを参照してください。

それでも一部のボットが通り抜けるなら、Spam check を Ikiform にします。キーなしで Cloudflare Turnstile が加わり、ほとんどの訪問者には何も表示されません。CAPTCHA を追加するのはそのタイミング、つまりスパムフォルダーがそう示したときであって、それより前ではありません。

動作を確認する

  1. フォームを読み込み、1 秒以内に送信します。自分のハンドラーなら、送信は too_fast 付きでスパムとして保存されます。
  2. ブラウザーの開発者ツールで hidden の rendered の値を書き換えて送信します。invalid_render としてスコアが付きます。
  3. ハニーポットを _gotcha=bot と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
  4. リンクを 3 つ含み、他に怪しい点のないメッセージを送ります。受信されます。
  5. 何も届きませんか?ダッシュボードのステータスフィルターを Spam に切り替えてください。スパムにも本物の送信データと同じ成功の応答が返ります。

ハマりどころ

  • 静的ページ。 デプロイ時に 1 回だけビルドされるページは、すべての訪問者で表示時刻が同じになります。リクエストごとに署名するか、フォームが表示されたときに JavaScript で新しいトークンを取得してください。
  • パスワードマネージャーと貼り付け。 自動入力で 1 秒でフォームを埋める人もいます。時間計測は弱いシグナルのままにして、それだけでブロックしないようにしてください。
  • 語句リストは古くなります。 スパムの語句は変わります。数週間ごとに通り抜けたものを確認し、一般的な単語ではなく具体的な語句を追加してください。
  • 繰り返しは普通のことです。 返事が来なければ人は再送します。同一内容の繰り返しには重みを付けますが、即座にブロックはしないでください。

よくある質問

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