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

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

<AdminScreen
  name='inquiry-spam'
  alt='スパムの疑いのタブ。保存された 2 件と、Slack には通知していないという説明'
  caption='スパムの疑いがあるものも捨てずに保存し、Slack には通知しません。'
/>

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

### honeypot

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

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

```astro title="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 欄に入力された値そのものは残しません。値の種類や長さは問わないため（リクエスト全体のサイズ上限は除く）、長い値でも応答は変わりません。

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

```ts title="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` も見るので、ほかのサイトやほかのフォーム用のトークンは使い回せません。

```ts title="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 つの項目を確かめます。

```ts title="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 を設定する](https://goodnight-web.dev/ja/docs/setup/turnstile.md)を参照してください。

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

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

| ルール（保存する理由） | 点数                                                        | 対象                                                   |
| ---------------------- | ----------------------------------------------------------- | ------------------------------------------------------ |
| `links:N`              | リンク（`http://`、`https://`、`www.`）1 つにつき 1。最大 3 | すべての `textarea` の項目の合計                       |
| `link_in_field`        | 3                                                           | 名前や会社名など、1 行の `text` の項目のどれかにリンク |
| `too_fast`             | 3                                                           | フォームを表示してから 3 秒未満で送信                  |

```ts title="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` も）は保存し、問い合わせの詳細に表示します。

```ts title="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 通知の対象は、スパムの疑いがない問い合わせだけです。[お問い合わせフォームの項目を変える](https://goodnight-web.dev/ja/docs/contact-form.md)を参照してください。
- **honeypot は Turnstile の後で扱います。** Turnstile より前に保存すると、ボットが Turnstile を通らずに D1 へ書き込めてしまいます。先に Turnstile、回数制限、検証を通すので、ボットが保存できる数は回数制限の範囲までです。保存される行は、すべて検証済みです。
- **項目は D1 に保存した定義で確かめます。** フォームの項目は管理者が管理画面で編集し、定義は D1 に保存します。サーバーは送信のたびに、その定義から zod のスキーマを組み立てます（[お問い合わせフォームの項目を変える](https://goodnight-web.dev/ja/docs/contact-form.md)を参照）。
  - 受け付けるのは現在の項目 ID だけです。細工したリクエストで項目を増やすことはできません。
  - 必須、文字数、形式、選択肢の値も確かめます。
  - 問い合わせ 1 件で保存する値は 16 KB までです。
- **回数制限には Cloudflare の binding を使います。** Rate Limiting binding は Worker のインスタンスをまたいで数え、無料プランでも使えます。Better Auth に組み込まれたメモリ上の制限はインスタンスをまたがないので、ここでは使いません。
- **ページにキーを埋め込みません。** 問い合わせページの API キーは誰でも読めるので、何も守れません。守るのは Turnstile と回数制限です。
