Repository navigation
Bot-proof /api/contact server-side (signed form token + rate limit) - #19
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_submittedfor the same week. The bots POST/api/contactdirectly 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.jscannot fix this. The gate has to be in the API route.What it does
Three gates in front of Resend, in order:
429_gotchafilled (unchanged contract)200, no mail403New
GET /api/form-tokenmints an HMAC-signedv1.<ms>.<nonce>.<sig>./api/contactrefuses 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 apackage.jsonon purpose, so the whole thing isnode:cryptoand nothing else.Keeping humans working
ContactFormfetches a token on mount and waits out the remaining minimum age before POSTing, so the 3-second floor never surfaces as an error.public/index.htmlmints 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.ReactSnapuser agent./contactand/zh/contactboth verified in a browser against the real build.CSP and analytics
No CSP change.
/api/form-tokenis same-origin, soconnect-src 'self'invercel.jsonalready 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 inindex.html, which deliberately carries no meta CSP.PostHog is untouched. A
403is now tracked ascontact_form_failedwithreason: '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 ascontact_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 insrc/__tests__/contactBotGate.test.jsthat call the handlers directly, the same way the bots reach them.npx eslintclean.End-to-end over loopback HTTP against the real handlers, Resend stubbed:
Browser, real build served with the API routes, both languages:
/contact/— oneGET /api/form-token200, onePOST /api/contact200, success panel "Thanks, Priya Menon. I'll reply within 24 hours.", 0 console errors./zh/contact/— success panel 「多謝你,陳嘉欣。我會在 24 小時內回覆。」, 0 console errors.to: sam@adaptig.comand the correctreply_to.Deploy notes
CONTACT_FORM_SECRETis optional and falls back toRESEND_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.shnow hard-checksform-token.jsand_form-guard.jsalongsidecontact.js. A build missing either is a form that rejects humans.Pre-existing issue found, not fixed here
npm run predeploycannot complete on this machine right now. react-snap's pinned 2019 Chromium disconnects around route 26-30 and the run dies withCannot write to stream after nil, leaving ~28 prerendered routes against the ~121 the deploy guard requires. I reproduced this on a cleanmainwith 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 buildsucceeds, and the browser verification above ran against that build.