Skip to content

Bot-proof /api/contact server-side (signed form token + rate limit) - #19

Merged
HyperfocuSam merged 2 commits into
mainfrom
botproof-contact-api
Sep 19, 2026
Merged

HyperfocuSam merged 2 commits into
mainfrom
botproof-contact-api

Conversation

@HyperfocuSam

Copy link
Copy Markdown
Owner

Why

The 2026-09-06 and 2026-09-13 funnel digests found the same thing twice: every contact-form mail reaching sam@adaptig.com was spam, and PostHog recorded zero contact_form_started / contact_form_submitted for the same week. The bots POST /api/contact directly and never run the page's JavaScript, so the React honeypot and the front-end instrumentation are both invisible to them. Sam fired a real test inquiry on 7 Sep and it arrived, so the human path had to keep working unchanged.

Anything that lives in ContactForm.js cannot fix this. The gate has to be in the API route.

What it does

Three gates in front of Resend, in order:

Gate Rule Response
Rate limit 5 submissions / 10 min per IP, counting rejected attempts 429
Honeypot _gotcha filled (unchanged contract) fake 200, no mail
Form token signed token, ≥3s old, <2h old, unseen 403

New GET /api/form-token mints an HMAC-signed v1.<ms>.<nonce>.<sig>. /api/contact refuses any submission whose token is missing, forged, minted in the future, younger than 3 seconds, older than 2 hours, or already used. September's spam sends no token at all, so it never reaches Resend.

The rate limiter and the replay cache are in-memory (module scope on a warm Vercel instance). They are speed bumps on top of the signature and the age window, not distributed-flood defences. No KV, no Redis — api/ ships without a package.json on purpose, so the whole thing is node:crypto and nothing else.

Keeping humans working

  • ContactForm fetches a token on mount and waits out the remaining minimum age before POSTing, so the 3-second floor never surfaces as an error.
  • Every attempt burns a token (single-use), so a fresh one is minted after any failed submit — otherwise a retry after a 502 would be refused as a replay.
  • A bootstrap in public/index.html mints one as soon as a prerendered contact page loads. It runs before hydration and independently of the React bundle, so the native no-JS POST fallback still carries a token if the bundle never hydrates. It only fires on pages that actually contain the form.
  • Both fetches are skipped under the ReactSnap user agent.
  • /contact and /zh/contact both verified in a browser against the real build.

CSP and analytics

No CSP change. /api/form-token is same-origin, so connect-src 'self' in vercel.json already covers it and there is no third-party entry for a future edit to forget — the omission that silently ate 88 days of submissions in 2026. Nothing to mirror in index.html, which deliberately carries no meta CSP.

PostHog is untouched. A 403 is now tracked as contact_form_failed with reason: 'blocked', so humans tripping the gate show up as their own signal rather than as a generic server error. Server-side rejections that never run the page's JS still cannot reach PostHog by definition; they go to the Vercel function log as contact_blocked {reason, ip, ua}. The reason is never returned to the sender — naming the failed check is free tuning feedback for whoever is probing.

Rejections on the native POST path now return readable HTML with a mailto: fallback instead of raw JSON, so a refused human still has a door.

Test evidence

npx jest — 177 passed, 11 suites, including 9 new cases in src/__tests__/contactBotGate.test.js that call the handlers directly, the same way the bots reach them. npx eslint clean.

End-to-end over loopback HTTP against the real handlers, Resend stubbed:

1. BOT — blind POST, no token (exactly what the Sept spam does)   -> HTTP 403
2. BOT — fetches a token, posts it in the same instant            -> HTTP 403
3. HUMAN — token minted on page load, submitted 4s later          -> HTTP 200 {"ok":true}
4. BOT — replays the human token it just sniffed                  -> HTTP 403
5. BOT — honeypot filled, valid token                             -> HTTP 200 (no mail)
6. NO-JS native POST without a token                              -> HTTP 403 text/html + mailto
7. FLOOD — 7 blind POSTs from one address   -> 403 403 403 403 403 429 429

MAILS THE STUBBED RESEND WOULD HAVE SENT: 1
  - [hyperfocusam.com] Corporate Training — Sam Wong

Browser, real build served with the API routes, both languages:

  • /contact/ — one GET /api/form-token 200, one POST /api/contact 200, success panel "Thanks, Priya Menon. I'll reply within 24 hours.", 0 console errors.
  • /zh/contact/ — success panel 「多謝你,陳嘉欣。我會在 24 小時內回覆。」, 0 console errors.
  • Both messages captured at the stubbed Resend with to: sam@adaptig.com and the correct reply_to.

Deploy notes

  • CONTACT_FORM_SECRET is optional and falls back to RESEND_API_KEY, so this needs no new Vercel setting. Set it separately if the Resend key is ever rotated independently of live tokens.
  • scripts/deploy-vercel.sh now hard-checks form-token.js and _form-guard.js alongside contact.js. A build missing either is a form that rejects humans.
  • Not deployed. Draft on purpose — Sam's POST decision.

Pre-existing issue found, not fixed here

npm run predeploy cannot complete on this machine right now. react-snap's pinned 2019 Chromium disconnects around route 26-30 and the run dies with Cannot write to stream after nil, leaving ~28 prerendered routes against the ~121 the deploy guard requires. I reproduced this on a clean main with every change in this PR stashed, so it is not caused by this work — but it does mean a production deploy is blocked until react-snap is unstuck, and this branch could not be verified through the prerendered path. npm run build succeeds, and the browser verification above ran against that build.

Sam Wong added 2 commits September 17, 2026 22:23
…neypot

The 2026-09-06 and 2026-09-13 funnel digests found every contact-form mail
reaching sam@adaptig.com that week was spam, while PostHog recorded zero
contact_form_started / contact_form_submitted for the same period. The bots
POST /api/contact directly and never run the page's JavaScript, so both the
React honeypot and the front-end instrumentation are invisible to them. The
defence has to be server-side.

Three gates now stand in front of Resend, in order:

1. Per-IP rate limit — 5 submissions / 10 min, counting rejected attempts so a
   script gets no free tries at guessing a token. In-memory, so it is a warm-
   instance speed bump, not a distributed-flood defence. No KV: the build ships
   no dependencies.
2. Honeypot — unchanged _gotcha contract, still answered with a fake success so
   bots do not retry. Now logged.
3. Form token — new GET /api/form-token mints an HMAC-signed
   `v1.<ms>.<nonce>.<sig>`. /api/contact requires one that verifies, is at
   least 3s old, under 2h old, and has not been seen before. September's spam
   sends no token at all and is refused before Resend is ever called.

Front end: ContactForm fetches a token on mount and waits out the remaining
minimum age before POSTing, so the 3s floor is invisible to a human. Every
attempt burns a token, so one is re-minted after any failed submit or a retry
would be refused as a replay. A bootstrap in public/index.html mints one as
soon as a prerendered contact page loads — earlier than hydration, and
independent of the React bundle, so the native no-JS POST fallback still
carries a token if the bundle never hydrates. Both fetches are skipped under
the ReactSnap user agent.

No CSP change: /api/form-token is same-origin, so `connect-src 'self'` in
vercel.json already covers it and there is no third-party entry for a future
edit to forget — the omission that silently ate 88 days of submissions in 2026.

Rejections are answered with readable HTML (and the mailto) for the native
POST path, not raw JSON, and the reason is logged to the Vercel function log
rather than returned — naming the failed check is free tuning feedback for
whoever is probing. A 403 is tracked as contact_form_failed reason=blocked so
a spike of humans tripping the gate is visible instead of filed as a server
error.

deploy-vercel.sh now hard-checks form-token.js and _form-guard.js alongside
contact.js: a build missing either is a form that rejects humans.

Env: CONTACT_FORM_SECRET is optional and defaults to RESEND_API_KEY, so this
needs no new Vercel setting.

Tests: src/__tests__/contactBotGate.test.js, 9 cases against the handlers
themselves — the same way the bots reach them.
@HyperfocuSam
HyperfocuSam merged commit 448057c into main Sep 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant