Problem
CMS forms have four protections: the Origin check, the challenge token, the minimum fill time (minSubmitSeconds) and the honeypot (server/forms/handler.ts). All four prove that a browser sent the request. None of them proves a person drove that browser.
We now get spam from a bot that runs a real browser. It loads the page, the form runtime fetches a valid challenge, it fills only the visible fields and waits past the time trap. Every row passes validation and gets stored. The pattern is easy to recognise: each text field holds one random mixed-case string (kBDOSydWzHsUxYjvlhAZc), and the e-mail field holds a real, unrelated person's address.
The stored row is only half the damage. A plugin that sends the submitter a confirmation (ours does) now mails strangers on the bot's behalf, and the sending domain's reputation pays for it. A site owner has no switch in Instatic that stops this kind of bot.
Related: #380 asks for the existing rejections to become observable. A new rejection reason from this feature would travel the same path.
Proposed solution
An optional human-verification step on CMS forms, with the provider behind a small interface so the core does not hard-code one vendor.
- Form props (only when
mode === 'cms'): humanVerification: 'none' | 'turnstile', default 'none'. The union can grow later (hcaptcha, friendly-captcha) without touching the handler.
- Keys: the public site key and the secret come from server config (for example
INSTATIC_TURNSTILE_SITE_KEY and INSTATIC_TURNSTILE_SECRET_KEY), never from the page tree, so the secret cannot end up in a published snapshot.
- Publish: when a page contains a form with verification on, the publisher injects the provider's widget container and script and adds the provider's hosts to that page's CSP (
script-src, frame-src, connect-src as the provider requires). Pages without such a form keep the default strict CSP.
- Runtime:
formRuntimeJs.ts reads the provider's response token and sends it with the submission body.
- Server:
handleSubmit verifies the token with the provider (Turnstile siteverify, passing the client IP) after the honeypot check and before the table write. A failed or missing token returns 400. A provider timeout fails closed, with a clear log line.
- Tests + docs: a verifier interface with a fake in tests, handler tests for pass, fail and missing token, a CSP test for the injected hosts, and a section in the forms docs.
The smallest useful version is Turnstile only: it is free, needs no cookie banner, and stops browser-driven bots, which the existing protections cannot.
Alternatives considered
- Content filtering in a plugin. We run this as a stopgap: the notifier plugin flags rows whose fields look like random strings and skips the confirmation mail. It lowers the harm, but the rows are still stored and the heuristic can be dodged.
- Raising
minSubmitSeconds. The bot already waits past 2 s. A higher value slows down real visitors more than a patient bot.
- Proof of work (ALTCHA-style), self-hosted. No third party, but a headless browser solves the puzzle as easily as a person, so it only adds cost per submission.
- Doing it per site in custom scripts. Not possible cleanly: the page CSP defaults to
script-src 'none', and the server side has to verify the token before the row is written.
Area
Publishing
Problem
CMS forms have four protections: the Origin check, the challenge token, the minimum fill time (
minSubmitSeconds) and the honeypot (server/forms/handler.ts). All four prove that a browser sent the request. None of them proves a person drove that browser.We now get spam from a bot that runs a real browser. It loads the page, the form runtime fetches a valid challenge, it fills only the visible fields and waits past the time trap. Every row passes validation and gets stored. The pattern is easy to recognise: each text field holds one random mixed-case string (
kBDOSydWzHsUxYjvlhAZc), and the e-mail field holds a real, unrelated person's address.The stored row is only half the damage. A plugin that sends the submitter a confirmation (ours does) now mails strangers on the bot's behalf, and the sending domain's reputation pays for it. A site owner has no switch in Instatic that stops this kind of bot.
Related: #380 asks for the existing rejections to become observable. A new rejection reason from this feature would travel the same path.
Proposed solution
An optional human-verification step on CMS forms, with the provider behind a small interface so the core does not hard-code one vendor.
mode === 'cms'):humanVerification: 'none' | 'turnstile', default'none'. The union can grow later (hcaptcha,friendly-captcha) without touching the handler.INSTATIC_TURNSTILE_SITE_KEYandINSTATIC_TURNSTILE_SECRET_KEY), never from the page tree, so the secret cannot end up in a published snapshot.script-src,frame-src,connect-srcas the provider requires). Pages without such a form keep the default strict CSP.formRuntimeJs.tsreads the provider's response token and sends it with the submission body.handleSubmitverifies the token with the provider (Turnstilesiteverify, passing the client IP) after the honeypot check and before the table write. A failed or missing token returns 400. A provider timeout fails closed, with a clear log line.The smallest useful version is Turnstile only: it is free, needs no cookie banner, and stops browser-driven bots, which the existing protections cannot.
Alternatives considered
minSubmitSeconds. The bot already waits past 2 s. A higher value slows down real visitors more than a patient bot.script-src 'none', and the server side has to verify the token before the row is written.Area
Publishing