問い合わせフォームの保護

問い合わせ API が送信を確かめる順番と、疑わしい送信を捨てずに印を付けるしくみを説明します。

問い合わせ API(POST /api/contact)が送信をどう確かめ、どう保存するかを説明します。迷惑な送信は止めますが、疑わしいだけの送信は捨てずに、印を付けて残します。

確認の順番

API は次の表の上から順に確認します。軽い確認ほど先にするので、迷惑な送信の多くは早い段階で止まります。

確認失敗したとき
Origin がサイト自身のオリジンで、Content-Type が想定どおりで、本文が 32 KB まで本文を読む前に拒否
Turnstile の siteverify で success、hostname、action が一致する400。何も保存しない
IP アドレス(IPv6 は /64)のハッシュをキーにした Rate Limiting binding(60 秒に 5 回)429
現在のフォームの定義をもとに zod で各項目を検証する不正な入力や知らない項目は 400。古いバージョンのフォームからの送信は 409
honeypot(人には見えない入力欄)とスパムスコア失敗にはしない。保存したうえで「スパムの疑い」の印を付け、Slack には通知しない
直近 1 時間に保存した送信が、すべての送信者の合計で 500 件未満503 contact_busy。何も保存しない

すべての確認が終わったら、送信を D1 に保存します。200 を返すのはその後です。

1 時間あたりの上限(CONTACT_HOURLY_INSERT_QUOTA)は、Turnstile の突破に費用をかけて多くのアドレスから送る相手に、D1 を短時間で埋められないようにするためのものです。会社のサイトに届く問い合わせは 1 時間に 500 件よりずっと少ないため、ふつうの利用だけで上限に届くことはありません。攻撃で使い切られることはあり、上限はすべての送信者で共有します。上限に達している間、フォームは一般的なエラーを表示して、もう一度試すよう促します。何も保存しないので、管理画面にも届きません。

スパム対策

スパム対策は 3 段です。Turnstile がほとんどのボットを止めます。honeypot とスパムスコアは何も拒否せず、送信に スパムの疑い の印を付けるだけです。

スパムの疑いがある送信も、ほかと同じく保存します。ただし Slack には通知せず、管理画面では スパムの疑い のタブに分けて表示します。

スパムの疑いのタブ。保存された 2 件と、Slack には通知していないという説明スパムの疑いのタブ。保存された 2 件と、Slack には通知していないという説明
スパムの疑いがあるものも捨てずに保存し、Slack には通知しません。

本物の問い合わせだったら、管理者が開いて スパムではない を選びます。逆に、どの問い合わせにも スパムとして扱う で印を付けられます。

honeypot

フォームには、人には見えない 1 行の入力欄があります。すべての欄を埋めるボットは、この欄にも入力します。

欄は画面の外に置き、読み上げソフトからも隠します(aria-hidden)。Tab キーでも移動しません(tabindex="-1")。

templates/starter/src/features/contact/contact-form.astro
<div
  class='absolute -left-[9999px] h-px w-px overflow-hidden'
  aria-hidden='true'
>
  <label for='contact-gnw-hp-x'>{text.honeypot}</label>
  <input
    id='contact-gnw-hp-x'
    name='gnw_hp_x'
    data-contact-honeypot
    type='text'
    tabindex='-1'
    autocomplete='off'
    data-1p-ignore
    data-lpignore='true'
    data-bwignore
    data-form-type='other'
  />
</div>
  • 名前は gnw_hp_x です。 ブラウザやパスワード管理アプリにとって意味のない名前にしています。website や url のような名前だと自動入力され、本物の人の問い合わせに印が付いてしまいます。
  • 自動入力を止めます。 autocomplete="off" と data-* 属性で、よく使われるパスワード管理アプリ(1Password、LastPass、Bitwarden など)にこの欄を無視するよう伝えます。
  • 応答は変わりません。 honeypot が埋まった送信にも、有効な Turnstile のトークンと入力の検証が必要で、回数制限にも数えます。通れば理由 honeypot を付けて保存し、通らなければ、honeypot 欄が空の同じリクエストと同じエラーを返し、何も保存しません。ボットは応答から、わなにかかったかどうかを判別できません。
  • 値は保存しません。 honeypot 欄に入力された値そのものは残しません。値の種類や長さは問わないため(リクエスト全体のサイズ上限は除く)、長い値でも応答は変わりません。

サーバーは、この欄が埋まっているかを記録するだけで、先へ進みます。

packages/server/src/features/contact/contact-routes.ts
// 2. Honeypot: note it and carry on. A filled honeypot changes nothing
//    in the response; the submission still needs Turnstile, counts
//    against the rate limit, and must validate before it is stored as
//    suspected spam (docs/decisions/0009).
const honeypotValue = input[CONTACT_HONEYPOT_FIELD];
const honeypot =
  typeof honeypotValue === 'string'
    ? honeypotValue.trim() !== ''
    : honeypotValue !== undefined && honeypotValue !== null;

Turnstile

Cloudflare Turnstile はページの中で動き、フォームにトークンを渡します。Worker はそのトークンを Turnstile の検証 API(siteverify)に送り、次の 3 つを確認します。

  • success
  • hostname: サイト自身のホスト
  • action: contact

サーバー側で確認しなければ、ボットはウィジェットを飛ばして送信できます。hostname と action も見るので、ほかのサイトやほかのフォーム用のトークンは使い回せません。

packages/server/src/features/contact/contact-routes.ts
const turnstile = await verifyTurnstile({
  secret: turnstileSecret,
  token,
  remoteIp: ip,
  hostnames: options.contact.turnstileHostnames ?? [
    new URL(c.req.url).hostname,
  ],
  action: CONTACT_TURNSTILE_ACTION,
});
if (!turnstile.ok) {
  console.warn(`contact: turnstile rejected (${turnstile.reason})`);
  return apiError(c, 400, 'turnstile_failed');
}

受け付けるホスト名は、既定ではリクエストが来たホスト名だけです。複数のホスト名でフォームを出すときは、createGoodnightWorker() の contact.turnstileHostnames オプションに並べます。

verifyTurnstile() は、ネットワークのエラー、200 以外の応答、想定外の本文を失敗として扱います。そのうえで 3 つの項目を確かめます。

packages/server/src/features/contact/turnstile.ts
const result = body as Record<string, unknown>;
if (result.success !== true) return { ok: false, reason: 'not_success' };
if (
  typeof result.hostname !== 'string' ||
  !expected.hostnames.includes(result.hostname)
) {
  return { ok: false, reason: 'hostname_mismatch' };
}
if (result.action !== expected.action) {
  return { ok: false, reason: 'action_mismatch' };
}
return { ok: true };

Turnstile の設定がなければ、フォームを止めます。TURNSTILE_SECRET と TURNSTILE_SITE_KEY がそろっていなければ、問い合わせ API は 503 を返し、フォームには「お問い合わせフォームは準備中です」と表示します。

IP アドレスのハッシュに使う鍵(APP_SECRET)か Rate Limiting binding がないときも同じです。どちらか一方でも欠けると、回数制限が動かないからです。Turnstile が設定済みなら、管理画面のお知らせに足りない設定の名前が出ます。Turnstile を設定するを参照してください。

ルールによるスパムスコア

検証の後、spam-score.ts の scoreSubmission() が、いくつかの決まったルールで点数を足します。ルールは項目名ではなく項目の種類を見るので、フォームを変えてもそのまま働きます。

ルール(保存する理由)点数対象
links:Nリンク(http://、https://、www.)1 つにつき 1。最大 3すべての textarea の項目の合計
link_in_field3名前や会社名など、1 行の text の項目のどれかにリンク
too_fast3フォームを表示してから 3 秒未満で送信
packages/server/src/features/contact/spam-score.ts
const links = textOf('textarea').join('\n').match(LINKS)?.length ?? 0;
if (links > 0) {
  score += Math.min(links, 3);
  reasons.push(`links:${links}`);
}
if (textOf('text').some((value) => HAS_LINK.test(value))) {
  score += 3;
  reasons.push('link_in_field');
}
if (elapsedMs !== undefined && elapsedMs < SPAM_MIN_ELAPSED_MS) {
  score += 3;
  reasons.push('too_fast');
}
return { score, reasons };

url の項目はリンクを書く欄なので数えません。合計が 3 以上(SPAM_SUSPECT_THRESHOLD)なら、スパムの疑いの印を付けます。

本文にリンクが 1 つあるだけでは 3 点に届かず、印は付きません。次のどれかに当てはまると付きます。

  • textarea の項目に、合わせて 3 つ以上のリンクがある。
  • 名前などの 1 行の欄にリンクがある。
  • 3 秒未満で送信された。

点数と理由(spam_reasons。わなにかかったときは honeypot も)は保存し、問い合わせの詳細に表示します。

packages/server/src/features/contact/contact-routes.ts
const suspectedSpam = honeypot || spam.score >= SPAM_SUSPECT_THRESHOLD;
const spamReasons = honeypot ? ['honeypot', ...spam.reasons] : spam.reasons;

なぜこう作るか

  • 怪しい送信は捨てずに印を付けます。 問い合わせを取りこぼす損失は、少しのスパムより大きいからです。本物の人がルールに当てはまることもあります(リンクを 3 つ書いた、パスワード管理アプリが隠れた欄を埋めた、など)。そのため、保存して分けておき、判断は管理者に任せます。
  • スパムの疑いは分けて表示します。 通常の一覧に混ぜると、本物の問い合わせがスパムに埋もれ、未読件数にも数えてしまいます。未読件数と Slack 通知の対象は、スパムの疑いがない問い合わせだけです。お問い合わせフォームの項目を変えるを参照してください。
  • honeypot は Turnstile の後で扱います。 Turnstile より前に保存すると、ボットが Turnstile を通らずに D1 へ書き込めてしまいます。先に Turnstile、回数制限、検証を通すので、ボットが保存できる数は回数制限の範囲までです。保存される行は、すべて検証済みです。
  • 項目は D1 に保存した定義で確かめます。 フォームの項目は管理者が管理画面で編集し、定義は D1 に保存します。サーバーは送信のたびに、その定義から zod のスキーマを組み立てます(お問い合わせフォームの項目を変えるを参照)。
    • 受け付けるのは現在の項目 ID だけです。細工したリクエストで項目を増やすことはできません。
    • 必須、文字数、形式、選択肢の値も確かめます。
    • 問い合わせ 1 件で保存する値は 16 KB までです。
  • 回数制限には Cloudflare の binding を使います。 Rate Limiting binding は Worker のインスタンスをまたいで数え、無料プランでも使えます。Better Auth に組み込まれたメモリ上の制限はインスタンスをまたがないので、ここでは使いません。
  • ページにキーを埋め込みません。 問い合わせページの API キーは誰でも読めるので、何も守れません。守るのは Turnstile と回数制限です。

目次