# Contact form protection

> The order in which the contact API checks a submission, and how suspicious submissions are flagged instead of dropped.

This page explains how the contact API (`POST /api/contact`) checks and saves a submission. It stops junk, but a submission that is only suspicious is flagged and kept, not dropped.

## The checks, in order

The API runs these checks from top to bottom. The cheap checks come first, so most junk is stopped early.

| Check                                                                                           | On failure                                                                                          |
| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `Origin` is the site's own origin, `Content-Type` is as expected, and the body is at most 32 KB | Rejected before the body is parsed                                                                  |
| Turnstile siteverify: `success`, `hostname`, and `action` match                                 | 400, nothing saved                                                                                  |
| Rate Limiting binding, keyed by the hashed IP address or IPv6 /64 (5 requests per 60 seconds)   | 429                                                                                                 |
| zod validation of the fields against the current form definition                                | Invalid input or an unknown field is 400; a submission from an old form version is 409              |
| Honeypot and spam score                                                                         | Never a failure: the submission is saved, flagged as suspected spam, and gets no Slack notification |
| Fewer than 500 submissions stored in the last hour, across all senders                          | 503 `contact_busy`, nothing saved                                                                   |

When all checks pass, the submission is saved to D1. Only then does the API return 200.

The hourly limit (`CONTACT_HOURLY_INSERT_QUOTA`) keeps D1 from being filled quickly by someone who pays for Turnstile solves from many addresses. A company site gets far fewer inquiries than 500 an hour, so normal traffic does not reach it on its own. While it is reached (an attack can use it up, and the limit is shared by every sender), the form shows its general error and asks the visitor to try again; nothing is saved, so nothing reaches the admin screen either.

## Spam protection

There are three layers. Turnstile keeps most bots out. The honeypot and the spam score do not reject anything; they only flag a submission as **suspected spam**.

A suspected spam submission is saved like any other. It gets no Slack notification, and the admin screen lists it separately, under the **Suspected spam** tab.

<AdminScreen
  name='inquiry-spam'
  alt='The Suspected spam view with two saved inquiries and a note that no Slack notification was sent for them.'
  caption='Suspected spam is saved, not dropped, and never sent to Slack.'
/>

If it is a real inquiry, an admin opens it and chooses **Not spam**. An admin can also flag any inquiry with **Mark as spam**.

### Honeypot

The form has a one-line text field that people cannot see. Bots that fill in every field fill in this one too.

The field is moved off screen and hidden from screen readers (`aria-hidden`). The Tab key skips it (`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>
```

- **The name is `gnw_hp_x`.** It means nothing to browsers and password managers. A name such as `website` or `url` invites autofill, and an autofilled honeypot would flag a real person's inquiry.
- **Autofill is turned off.** `autocomplete="off"` and the `data-*` attributes ask the common password managers (1Password, LastPass, Bitwarden, and others) to leave the field alone.
- **The response does not change.** A submission with a filled honeypot still needs a valid Turnstile token, counts against the rate limit, and must pass validation, like any other. If it passes, it is saved with the reason `honeypot`. If not, the client gets the same error as the same request with an empty honeypot, and nothing is saved. A bot cannot tell from the response that it hit the trap.
- **The value is not stored.** What was typed into the honeypot field is never saved. Any value is accepted, however long (within the request size limit above), so a long value does not change the response either.

The server only notes whether the field is filled and carries on:

```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 runs in the page and gives the form a token. The Worker sends the token to Turnstile's `siteverify` API and checks three things:

- `success`
- `hostname`: the site's own host
- `action`: `contact`

Without this server-side check, a bot could skip the widget. Because `hostname` and `action` are checked too, a token made for another site or another form cannot be reused here.

```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');
}
```

By default, the only accepted hostname is the hostname of the incoming request. If you serve the form on more than one hostname, list them in the `contact.turnstileHostnames` option of `createGoodnightWorker()`.

`verifyTurnstile()` treats a network error, a non-200 response, or an unexpected body as a failure. Then it checks the three fields:

```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 };
```

Without Turnstile, the form is off. If `TURNSTILE_SECRET` or `TURNSTILE_SITE_KEY` is missing, the contact API returns 503 and the form says "The contact form is not available yet".

The same happens when the key for IP hashes (`APP_SECRET`) or the Rate Limiting binding is missing, because the rate limit cannot run if either one is missing. If Turnstile is set up, the admin screen's notice names the missing settings. See [Set up Turnstile](https://goodnight-web.dev/docs/setup/turnstile.md).

### Rule-based spam score

After validation, `scoreSubmission()` in `spam-score.ts` adds points with a few fixed rules. The rules look at field types, not field names, so they keep working when you change the form.

| Rule (stored reason) | Points                                                | Applies to                                                     |
| -------------------- | ----------------------------------------------------- | -------------------------------------------------------------- |
| `links:N`            | 1 per link (`http://`, `https://`, `www.`), at most 3 | All `textarea` fields together                                 |
| `link_in_field`      | 3                                                     | A link in any one-line `text` field, such as a name or company |
| `too_fast`           | 3                                                     | Sent less than 3 seconds after the form was shown              |

```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` fields are meant to hold links, so they are not counted. A score of 3 or more (`SPAM_SUSPECT_THRESHOLD`) flags the submission as suspected spam.

A single link in a message does not reach 3. Any one of these does:

- three or more links across the `textarea` fields,
- a link in a one-line field such as the name,
- a form sent in under 3 seconds.

The score and the reasons (`spam_reasons`, plus `honeypot` when the trap was hit) are stored and shown in the inquiry detail:

```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;
```

## Why it is built this way

- **Suspicious submissions are flagged, not dropped.** A missed inquiry costs more than a little spam. A real person can trip a rule (a message with three links, a password manager that fills a hidden field), so suspected spam is saved and kept separate, and the admin decides.
- **Suspected spam is listed separately.** Mixed into the normal list, spam would bury real inquiries and inflate the unread count. The unread count and the Slack notification cover only inquiries that are not suspected spam. See [Customizing the contact form](https://goodnight-web.dev/docs/contact-form.md).
- **The honeypot is handled after Turnstile.** Saving a honeypot hit before Turnstile would let a bot write to D1 without passing it. Because Turnstile, the rate limit, and validation come first, a bot can store no more than the rate limit allows, and every stored row is validated.
- **Fields are checked against the definition stored in D1.** Admins edit the form fields in the admin screen, and the definition is stored in D1. On every submission, the server builds the zod schema from that definition (see [Customizing the contact form](https://goodnight-web.dev/docs/contact-form.md)).
  - Only the current field IDs are accepted, so a crafted request cannot add fields.
  - Required fields, lengths, formats, and choice values are checked.
  - The stored values of one inquiry are limited to 16 KB.
- **The rate limit uses Cloudflare's binding.** The Rate Limiting binding counts across Worker instances and works on the free plan. Better Auth's built-in in-memory limiter does not count across instances, so it is not used for this.
- **No key in the page.** An API key in the contact page could be read by anyone, so it would protect nothing. Protection comes from Turnstile and the rate limit.
