Skip to content

feat: Cloudflare Turnstile as the spam protection of public surveys - #247

Merged
houko merged 11 commits into
mainfrom
feat/turn-turnstile-spam-protection
Oct 28, 2026
Merged

houko merged 11 commits into
mainfrom
feat/turn-turnstile-spam-protection

Conversation

@houko

@houko houko commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

What & why

Was: Survey spam protection worked only with Google reCAPTCHA; Turnstile keys protected sign-up and nothing else.

Now: When TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set, Turnstile is the spam protection of public surveys (link pages and the web SDK widget); an instance with only reCAPTCHA keys keeps reCAPTCHA.

  • The runtime renders an interaction-only Turnstile widget once per survey, in the survey's language, and sends its token as the new optional turnstileToken on the first response create. Updates need no token.
  • Each create attempt takes a token of its own: the response queue asks the widget for a token on every create, including a retry after a 500/503 and a stored create sent after a reload, and the widget hands each token out once and resets for the next. A token the server may already have spent is never sent again.
  • A Turnstile error that Cloudflare retries on its own (300xxx, 600xxx, challenge timeouts) keeps the submit waiting up to 20 s for the retry; configuration errors (110xxx) and unsupported browsers fail at once.
  • The server verifies it with the shared verifier (now internal/platform/turnstile), passing the trusted client IP and the expected action (submit_response for responses, sign_up for sign-up, which the sign-up widget now names), so a token minted by one form is refused by the other. A token without an action (Cloudflare's test keys, a page rendered before this change) is still taken. A refused or missing token is a 400 with the existing recaptcha_verification_failed code; Cloudflare not answering is a 503 spam_protection_unavailable, never an accept.
  • While both key pairs are configured, a create that carries no turnstileToken but a recaptchaToken is checked by reCAPTCHA against the survey's threshold, so pages and SDK state cached from before the switch keep working.
  • The surveys runtime advertises supportsTurnstile on window.formaSurveys (additive). js-core does not show a Turnstile-protected survey through a runtime without it (a stale edge-cached /js/surveys.umd.js), instead of showing it and refusing the first answer.
  • The editor's switch names the provider in use; reCAPTCHA's score threshold shows only for reCAPTCHA. Survey page CSP allows challenges.cloudflare.com for the Turnstile provider; sign-up already did, and the e2e confirms it loads.

Where to look

Breaking changes

  • This PR contains breaking changes
Change Before After Who's affected Action required
Spam protection provider when both Turnstile and reCAPTCHA keys are set reCAPTCHA Turnstile; the environment state sends turnstileSiteKey instead of recaptchaSiteKey. reCAPTCHA tokens are still accepted while the reCAPTCHA keys stay set Self-hosters with both key pairs and surveys with spam protection on Add the sites that embed SDK surveys to the Turnstile widget's hostnames, or remove the Turnstile keys to stay on reCAPTCHA
SDK bundles /js/forma.umd.js and /js/surveys.umd.js Unversioned and served with s-maxage=2592000: an edge copy from before this release does not know Turnstile App surveys with spam protection on, once Turnstile keys are set Purge both URLs from the Cloudflare edge cache when rolling this release out, and again when the Turnstile keys are first set. A stale forma.umd.js would show protected surveys without a token (the server refuses the first answer); a stale surveys.umd.js behind a fresh forma.umd.js is now caught by js-core, which skips the survey

Migrations & env

  • No migration. TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY now also turn on survey spam protection (.env.example, compose comments updated).
  • forma.yldm.ai has neither key pair today, so this changes nothing there until the keys are added. When adding them: purge https://forma.yldm.ai/js/forma.umd.js and https://forma.yldm.ai/js/surveys.umd.js from the Cloudflare cache (purge by URL).

How this was tested

Rerun: cd apps/server && go test ./internal/platform/turnstile ./internal/clientapi ./internal/linksurvey ./internal/auth ./internal/platform/config and E2E_TURNSTILE=pass|fail vp exec playwright test turnstile.spec.ts (e2e.yml restarts the stack with Cloudflare's test keys)

Coverage

Behaviour How Outcome
Verify: valid, invalid, spent, missing, Cloudflare down, timeout, bad secret, another widget's action, no action unit (guard) TestVerify* Only success for the expected action (or none) accepts; outages return ErrUnavailable
A retried or later create never reuses a token; stored creates after a reload get one; no token keeps a stored answer unit (red before the fix) turnstile.test.ts, response.queue.test.ts, survey.test.tsx Each attempt sends a fresh token
A retryable Turnstile error waits for Turnstile's retry, bounded at 20 s unit (red before the fix) turnstile.test.ts Submit succeeds after the retry, fails after 20 s
Stale runtime without Turnstile support is not used for a protected survey unit (red before the fix) widget.test.ts Survey skipped, isSurveyRunning released
reCAPTCHA token during the switch; sign-up token on a response unit TestCreateResponseTurnstile 200 via reCAPTCHA while its keys are set, else 400; sign-up token 400
Response create with Turnstile: missing/invalid 400, down 503, valid 200, update without token 200 unit TestCreateResponseTurnstile + 7 new contract exchanges As listed; nothing written without an accepted token
Survey page CSP and page state name Turnstile only for its provider unit (red on main) TestSurveyPagePolicyWithTurnstile, TestSurveyPageTurnstileSiteKey Fail on main, pass here
Sign-up page loads Turnstile under its CSP; link survey on a phone sends the token e2e turnstile.spec.ts (pass keys) Token XXXX.DUMMY.TOKEN.XXXX sent, response stored, no CSP violations
SDK widget on a customer page renders Turnstile and sends the token e2e turnstile.spec.ts (pass keys) Response created with the token
Always-fail secret refuses the response and the respondent sees the spam error e2e turnstile.spec.ts (fail secret) 400 recaptcha_verification_failed, error card shown

Open gaps

  • Turnstile validates hostnames: SDK surveys on customer domains not in the widget's hostname list cannot be submitted (the editor note says so). reCAPTCHA had the same limit.
  • Native mobile SDKs are not covered, as with reCAPTCHA.

Risks

  • Every create attempt runs a fresh (usually invisible) Turnstile challenge after the first; a respondent may see an interactive challenge again on a retry.
  • Turnstile keys set for sign-up only now also switch survey spam protection away from reCAPTCHA.

houko added 6 commits October 28, 2026 09:35
…outage

The sign-up verifier moves to internal/platform/turnstile so the client API can use it too. Verify now sends the trusted client address as remoteip and returns ErrUnavailable when Cloudflare gives no verdict (request failure, non-200, unreadable answer, internal-error or a bad secret), instead of folding that into a refusal. Sign-up still refuses on both, and logs the outage.
- config.SpamProtectionProvider picks Turnstile whenever both TURNSTILE_* keys are set, else reCAPTCHA, else none; surveys may turn spam protection on with either.
- The client API response create takes an optional turnstileToken and, on a Turnstile instance, verifies it before the first write of a response to a survey with spam protection on: missing or refused is 400 with the existing recaptcha_verification_failed code, Cloudflare not answering is 503 spam_protection_unavailable. Updates need no token.
- The environment state and the survey page name the active provider's site key, the survey page CSP allows challenges.cloudflare.com for Turnstile, and /auth/options reports spamProtectionProvider.
- New contract exchanges for a Turnstile instance, replayed by TestContractExchangesTurnstile.
…the SDK

With turnstileSiteKey and spam protection on, the runtime renders one interaction-only Turnstile widget per survey, in the survey's language, and sends its token as turnstileToken with the create of the response; a missing token shows the existing spam protection error. The SDK loads Turnstile's script before it shows such a survey and hands the key to the runtime, as it does for reCAPTCHA, and the link survey page passes the key the server names.
…hreshold for reCAPTCHA

With Turnstile the spam protection switch says it uses Cloudflare Turnstile, shows a note on where it works and no score threshold, and the preview shows no reCAPTCHA notice. New keys live under workspace.surveys.edit.turnstile_spam_protection in every locale.
ci-stack.sh gains restart, which starts the server again with the current environment (and refuses to start while something still answers on its port). e2e.yml restarts it with the always-pass site key, first with the secret that accepts its token (sign-up, a link survey on a phone, the SDK widget), then with the one that refuses every token (the respondent sees the spam protection error and nothing is stored).
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

🚨 PR Size Warning

This PR has approximately 1163 lines of changes (951 additions, 212 deletions across 48 files).

Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.

💡 Suggestions:

  • Split by feature or module - Break down into logical, independent pieces
  • Create a sequence of PRs - Each building on the previous one
  • Branch off PR branches - Don't wait for reviews to continue dependent work

📊 What was counted:

  • ✅ Source files, stylesheets, configuration files
  • ❌ Excluded 32 files (tests, locales, locks, generated files)

📚 Guidelines:

  • Ideal: 300-500 lines per PR
  • Warning: 500-800 lines
  • Critical: 800+ lines ⚠️

If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.

houko added 5 commits October 28, 2026 11:08
Cloudflare accepts a Turnstile token once, and the server may spend it before the create fails (a 500 after siteverify, or the 503 when the verifier timed out after Cloudflare answered). The queue resent the one token it was given on every retry and after a reload sent stored creates with none, so a respondent ended on the terminal spam protection error.

The response queue now takes a token provider and asks it on every create attempt, stored creates included. The challenge hands each token to one caller and resets the widget for the next; the card only waits until a token is ready. A create without a token is not sent and keeps a stored answer for a later visit.

Errors Turnstile retries on its own (300xxx, 600xxx, challenge timeouts) now keep a submit waiting up to 20 s for the retry instead of failing at once.
…Turnstile

/js/surveys.umd.js is unversioned and the edge may serve a copy from before Turnstile, which would ask reCAPTCHA for a token it cannot issue and refuse the respondent's first answer. The runtime now advertises supportsTurnstile on window.formaSurveys (additive), and js-core leaves out a protected survey when the loaded runtime lacks it.
…ing the switch

Verify now compares the action siteverify reports with the form's: responses expect submit_response and sign-up expects sign_up, which the sign-up widget now names, so a token minted by one form is refused by the other. A token without an action (Cloudflare's test keys, a page rendered before this change) is still taken.

While reCAPTCHA keys stay configured beside Turnstile's, a create with no turnstileToken but a recaptchaToken is checked by reCAPTCHA against the survey's threshold, so pages and SDK state cached from before the switch are not refused.
@houko
houko merged commit 067ccfb into main Oct 28, 2026
21 checks passed
@houko
houko deleted the feat/turn-turnstile-spam-protection branch October 28, 2026 03:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant