Skip to content

[Feature]: Optional human verification (Turnstile) for CMS forms #568

Description

@borskyj-symph

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions