Repository navigation
feat: Cloudflare Turnstile as the spam protection of public surveys - #247
Merged
Merged
Conversation
…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).
🚨 PR Size WarningThis 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:
📊 What was counted:
📚 Guidelines:
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. |
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.
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.
What & why
Was: Survey spam protection worked only with Google reCAPTCHA; Turnstile keys protected sign-up and nothing else.
Now: When
TURNSTILE_SITE_KEYandTURNSTILE_SECRET_KEYare set, Turnstile is the spam protection of public surveys (link pages and the web SDK widget); an instance with only reCAPTCHA keys keeps reCAPTCHA.turnstileTokenon the first response create. Updates need no token.internal/platform/turnstile), passing the trusted client IP and the expected action (submit_responsefor responses,sign_upfor 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 existingrecaptcha_verification_failedcode; Cloudflare not answering is a 503spam_protection_unavailable, never an accept.turnstileTokenbut arecaptchaTokenis checked by reCAPTCHA against the survey's threshold, so pages and SDK state cached from before the switch keep working.supportsTurnstileonwindow.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.challenges.cloudflare.comfor the Turnstile provider; sign-up already did, and the e2e confirms it loads.Where to look
Breaking changes
turnstileSiteKeyinstead ofrecaptchaSiteKey. reCAPTCHA tokens are still accepted while the reCAPTCHA keys stay set/js/forma.umd.jsand/js/surveys.umd.jss-maxage=2592000: an edge copy from before this release does not know Turnstileforma.umd.jswould show protected surveys without a token (the server refuses the first answer); a stalesurveys.umd.jsbehind a freshforma.umd.jsis now caught by js-core, which skips the surveyMigrations & env
TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEYnow also turn on survey spam protection (.env.example, compose comments updated).https://forma.yldm.ai/js/forma.umd.jsandhttps://forma.yldm.ai/js/surveys.umd.jsfrom 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/configandE2E_TURNSTILE=pass|fail vp exec playwright test turnstile.spec.ts(e2e.yml restarts the stack with Cloudflare's test keys)Coverage
TestVerify*successfor the expected action (or none) accepts; outages returnErrUnavailableturnstile.test.ts,response.queue.test.ts,survey.test.tsxturnstile.test.tswidget.test.tsisSurveyRunningreleasedTestCreateResponseTurnstileTestCreateResponseTurnstile+ 7 new contract exchangesTestSurveyPagePolicyWithTurnstile,TestSurveyPageTurnstileSiteKeyturnstile.spec.ts(pass keys)XXXX.DUMMY.TOKEN.XXXXsent, response stored, no CSP violationsturnstile.spec.ts(pass keys)turnstile.spec.ts(fail secret)recaptcha_verification_failed, error card shownOpen gaps
Risks