問い合わせフォームの保護
問い合わせ 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 には通知せず、管理画面では スパムの疑い のタブに分けて表示します。


本物の問い合わせだったら、管理者が開いて スパムではない を選びます。逆に、どの問い合わせにも スパムとして扱う で印を付けられます。
honeypot
フォームには、人には見えない 1 行の入力欄があります。すべての欄を埋めるボットは、この欄にも入力します。
欄は画面の外に置き、読み上げソフトからも隠します(aria-hidden)。Tab キーでも移動しません(tabindex="-1")。
<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 欄に入力された値そのものは残しません。値の種類や長さは問わないため(リクエスト全体のサイズ上限は除く)、長い値でも応答は変わりません。
サーバーは、この欄が埋まっているかを記録するだけで、先へ進みます。
// 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 つを確認します。
successhostname: サイト自身のホストaction:contact
サーバー側で確認しなければ、ボットはウィジェットを飛ばして送信できます。hostname と action も見るので、ほかのサイトやほかのフォーム用のトークンは使い回せません。
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 つの項目を確かめます。
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_field | 3 | 名前や会社名など、1 行の text の項目のどれかにリンク |
too_fast | 3 | フォームを表示してから 3 秒未満で送信 |
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 も)は保存し、問い合わせの詳細に表示します。
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 と回数制限です。