diff --git a/.env.test b/.env.test index e09d5804a..e00a7209d 100644 --- a/.env.test +++ b/.env.test @@ -39,3 +39,6 @@ RAILS_ACTIVE_RECORD_KEY_DERIVATION_SALT= API_GATEWAY_BASE_URL=https://api-gateway.example.gov +TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA + diff --git a/app/views/components/widget/_fba.js.erb b/app/views/components/widget/_fba.js.erb index 2d72ef6f0..8d64b8aac 100644 --- a/app/views/components/widget/_fba.js.erb +++ b/app/views/components/widget/_fba.js.erb @@ -44,7 +44,7 @@ function FBAform(d, N) { this.options.formSpecificScript(); } <%- if form.enable_turnstile? %> - this.loadTurnstile(); + this.lazyLoadTurnstile(); <% end %> <%- if form.has_rich_text_questions? %> this.loadQuill(); @@ -212,12 +212,22 @@ function FBAform(d, N) { var self = this; if (self.validateForm(formElement)) { <%- if form.enable_turnstile? %> - const elapsed = Date.now() - self.turnstileInitiatedAt; - if (elapsed > 5 * 60 * 1000) { - self.initTurnstile(); - self.turnstileInitiatedAt = Date.now(); - return false; - } + var input = this.turnstileResponseInput(); + if (!input || !input.value) { + <%# Re-enable the submit button(s) so the user can resubmit after re-verifying. %> + var submitButtons = this.formElement().querySelectorAll("[type='submit']"); + Array.prototype.forEach.call(submitButtons, function(submitButton) { + submitButton.disabled = false; + submitButton.classList.remove("aria-disabled"); + }); + self.showTurnstileMessage("<%= I18n.t 'form.turnstile_verify' %>"); + return false; + } +// if (!self.turnstileTokenIsValid()) { +// self.refreshTurnstile(); +// self.showTurnstileMessage("<%= I18n.t 'form.turnstile_verify' %>"); +// return false; +// } <% end %> // disable submit button @@ -236,6 +246,13 @@ function FBAform(d, N) { var self = this; var formElement = this.formElement(); if (self.validateForm(formElement)) { + <%- if form.enable_turnstile? %> + if (!self.turnstileTokenIsValid()) { + self.refreshTurnstile(); + self.showTurnstileMessage("<%= escape_javascript(I18n.t('form.turnstile_verify', default: 'Please complete the verification challenge again, then resubmit.')) %>"); + return false; + } + <% end %> var submitButtons = formElement.querySelectorAll("[type='submit']"); Array.prototype.forEach.call(submitButtons, function(submitButton) { submitButton.disabled = true; @@ -583,7 +600,11 @@ function FBAform(d, N) { xhr.onload = callback.bind(this); xhr.send(JSON.stringify({ <%- if form.enable_turnstile? %> - "cf-turnstile-response" : form.querySelector("input[name='cf-turnstile-response']") ? form.querySelector("input[name='cf-turnstile-response']").value : null, + "cf-turnstile-response" : (function() { + var turnstileInput = form.querySelector("input[name='cf-turnstile-response']"); + var token = turnstileInput ? turnstileInput.value.trim() : ""; + return token.length > 0 ? token : null; + })(), <% end %> "submission": params, <%- if form.verify_csrf? %> @@ -721,23 +742,171 @@ function FBAform(d, N) { }, <% end %> <%- if form.enable_turnstile? %> + <%# Tokens are single-use and Cloudflare expires them after ~5 minutes. %> + <%# Treat a token older than 4 minutes (or absent/cleared) as invalid. %> + turnstileTokenMaxAgeMs: 4 * 60 * 1000, + turnstileTokenIsValid: function() { + var input = this.turnstileResponseInput(); + if (!input || !input.value) { + return false; + } + if (typeof this.turnstileTokenIssuedAt !== 'number') { + return false; + } + return (Date.now() - this.turnstileTokenIssuedAt) < this.turnstileTokenMaxAgeMs; + }, + turnstileResponseInput: function() { + return this.formComponent().querySelector("input[name='cf-turnstile-response']"); + }, + turnstileContainer: function() { + return this.formComponent().querySelector("#turnstile-container"); + }, + clearTurnstileToken: function() { + var input = this.turnstileResponseInput(); + if (input) { + input.value = ""; + } + this.turnstileTokenIssuedAt = null; + }, + showTurnstileMessage: function(message) { + var container = this.turnstileContainer(); + if (!container) { + return; + } + var messageEl = container.parentNode.querySelector(".fba-turnstile-message"); + if (!messageEl) { + messageEl = d.createElement("div"); + messageEl.setAttribute("class", "fba-turnstile-message usa-error-message"); + messageEl.setAttribute("role", "alert"); + container.parentNode.insertBefore(messageEl, container.nextSibling); + } + messageEl.innerText = message; + }, + clearTurnstileMessage: function() { + var container = this.turnstileContainer(); + if (!container) { + return; + } + var messageEl = container.parentNode.querySelector(".fba-turnstile-message"); + if (messageEl) { + messageEl.parentNode.removeChild(messageEl); + } + }, + <%# Defer loading the Turnstile challenge until the user actually engages with %> + <%# the form, so we don't run a challenge for the many visitors who never %> + <%# interact. Triggers on first form interaction and on modal open. %> + lazyLoadTurnstile: function() { + var self = this; + this.turnstileRequested = false; + + <%# Load the challenge at most once, on the first sign of engagement. %> + var trigger = function() { + <%# One-shot guard: load the Turnstile API + render the widget only once. %> + if (self.turnstileRequested) { + return; + } + self.turnstileRequested = true; + self.loadTurnstile(); + }; + + <%# First interaction with any form field (covers all delivery methods). %> + <%# focusin bubbles (unlike focus); { once: true } auto-removes the listener. %> + var formElement = this.formElement(); + if (formElement) { + formElement.addEventListener('focusin', trigger, { once: true }); + formElement.addEventListener('input', trigger, { once: true }); + } + }, loadTurnstile: function() { - let script = document.createElement("script"); - script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; - script.async = true; - script.defer = true; - script.onload = this.initTurnstile; - this.turnstileInitiatedAt = Date.now(); - if (!window.turnstile) { - document.head.appendChild(script); + var self = this; + if (window.turnstile) { + <%# API already loaded (host page or another form loaded it) — render now. %> + this.initTurnstileWidget(); + return; } + var script = d.createElement("script"); + script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; + script.onload = function() { + self.initTurnstileWidget(); + }; + script.onerror = function() { + console.error("Touchpoints: failed to load Cloudflare Turnstile API."); + }; + d.head.appendChild(script); }, - initTurnstile: function() { - turnstile.remove("#turnstile-container"); - turnstile.render("#turnstile-container", { + refreshTurnstile: function() { + <%# Re-enable the submit button(s) so the user can resubmit after re-verifying. %> + var submitButtons = this.formElement().querySelectorAll("[type='submit']"); + Array.prototype.forEach.call(submitButtons, function(submitButton) { + submitButton.disabled = false; + submitButton.classList.remove("aria-disabled"); + }); + this.clearTurnstileToken(); + this.initTurnstileWidget(); + }, + initTurnstileWidget: function() { + var self = this; + this.turnstileTokenIssuedAt = null; + if (!window.turnstile) { + <%# Script not ready yet; loadTurnstile's onload will invoke initTurnstile. %> + return; + } + var container = this.turnstileContainer(); + if (!container) { + console.error("Touchpoints: Turnstile container not found."); + return; + } +// if (this.turnstileWidgetId != null) { + <%# Reset the existing widget rather than remove/re-render to avoid errors. %> +// try { +// turnstile.reset(this.turnstileWidgetId); +// this.clearTurnstileToken(); +// return; +// } catch (e) { + <%# Fall through to a fresh render if reset fails. %> +// try { turnstile.remove(this.turnstileWidgetId); } catch (removeErr) {} +// this.turnstileWidgetId = null; +// } +// } + this.turnstileWidgetId = turnstile.render(container, { sitekey: "<%= ENV['TURNSTILE_SITE_KEY'] %>", + retry: "never", callback: function (token) { - document.querySelector("input[name='cf-turnstile-response']").value = token; + var input = self.turnstileResponseInput(); + if (input) { + input.value = token; + } + // self.turnstileTokenIssuedAt = Date.now(); + // self.clearTurnstileMessage(); + }, +// 'expired-callback': function () { + <%# Cloudflare expired the token; drop it so we never submit a stale token. %> +// self.clearTurnstileToken(); +// self.showTurnstileMessage("<%= escape_javascript(I18n.t('form.turnstile_expired', default: 'Verification expired. Please complete the challenge again.')) %>"); +// }, +// 'timeout-callback': function () { +// self.clearTurnstileToken(); +// }, + 'error-callback': function (errorCode) { + // self.clearTurnstileToken(); + + <%# Configuration errors (e.g. 110200 unauthorized domain, 1101xx bad %> + <%# sitekey) will never succeed on retry. Stop and log — do not re-render. %> + var code = String(errorCode || ""); + if (code.indexOf("1101") === 0 || code.indexOf("1102") === 0 || code.indexOf("4000") === 0) { + console.error("Touchpoints: Turnstile configuration error; not retrying.", errorCode); + return true; + } + + <%# Transient error: inform the user and retry once ourselves after a short delay. %> + console.error("Touchpoints: Turnstile error occurred.", errorCode); + self.showTurnstileMessage("<%= escape_javascript(I18n.t('form.turnstile_error', default: 'Verification could not be completed. Please try again.')) %>"); + N.setTimeout(function () { + if (self.turnstileWidgetId != null) { + try { turnstile.reset(self.turnstileWidgetId); } catch (e) {} + } + }, 2000); + return true; } }); }, diff --git a/config/locales/en.yml b/config/locales/en.yml index 3073daa65..f2863673a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -198,6 +198,9 @@ en: field_is_invalid: "Please enter a valid value: " chars_remaining: "Chars remaining: " enter_other_text: "Enter other text" + turnstile_verify: "Please complete the verification challenge, then resubmit." + turnstile_expired: "Verification expired. Please complete the challenge again." + turnstile_error: "Verification could not be completed. Please try again." header: official: "An official website of the United States government" heres_how_you_know: "Here’s how you know" diff --git a/config/locales/es.yml b/config/locales/es.yml index 1d8862aa5..9083d6e73 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -42,6 +42,9 @@ es: field_is_invalid: "Por favor ingrese una cifra válida: " chars_remaining: "Caracteres restantes: " enter_other_text: "Ingresa otro texto" + turnstile_verify: "Por favor, complete el desafío de verificación y luego vuelva a enviar." + turnstile_expired: "La verificación expiró. Por favor, complete el desafío nuevamente." + turnstile_error: "No se pudo completar la verificación. Por favor, inténtelo de nuevo." header: official: "Un sitio web oficial del gobierno de los Estados Unidos" heres_how_you_know: "Así es como sabes" diff --git a/docs/turnstile-metrics.md b/docs/turnstile-metrics.md new file mode 100644 index 000000000..7806963e2 --- /dev/null +++ b/docs/turnstile-metrics.md @@ -0,0 +1,93 @@ +# Measuring the Impact of Cloudflare Turnstile on a Form + +This guide describes how to decide whether enabling Cloudflare Turnstile on a +Touchpoints form is actually reducing spam, and what to measure before and +after turning it on. + +## How Turnstile is wired in this app + +- Turnstile is toggled per form via the `enable_turnstile` boolean + (`app/models/form.rb`, `app/views/admin/forms/_admin_options.html.erb`). +- On submission, if the form has Turnstile enabled, the server verifies the + `cf-turnstile-response` token against Cloudflare's siteverify endpoint + (`app/controllers/submissions_controller.rb`, `verify_turnstile`). +- On success, the submission is stamped + `spam_prevention_mechanism = "turnstile"`. +- On failure, an error is added and **the submission is not saved**. + +Because failed submissions are discarded and not logged or counted, the +database records what got through, not what was blocked. Keep this in mind +when interpreting the numbers below. + +## The one durable signal the app already stores + +`submissions.spam_prevention_mechanism`: + +- Set to `"turnstile"` only when verification passes. +- Defaults to `""` for everything else. + +Quick query for how many submissions were Turnstile-verified after enabling: + +```sql +SELECT spam_prevention_mechanism, COUNT(*) +FROM submissions +WHERE form_id = AND created_at >= '' +GROUP BY 1; +``` + +If Turnstile is enabled but few rows are stamped `turnstile`, that suggests a +configuration problem (e.g., missing `TURNSTILE_SITE_KEY` / +`TURNSTILE_SECRET_KEY`), not an absence of bots. + +## Metrics to capture BEFORE enabling (baseline) + +Use a stable window of 2-4 weeks on the same form: + +1. **Total submission volume per day** — the denominator for everything else. +2. **Spam/junk rate** — however you currently identify spam (manual triage, + keyword patterns, obvious bot content, duplicate bursts). This is the number + you are trying to move. +3. **Completion / abandonment** — any funnel data you have (widget opens vs. + submits). Turnstile adds friction, so watch for legitimate drop-off. +4. **Duplicate / burst patterns** — submissions from the same IP or in rapid + succession. IP is only stored if `organization.enable_ip_address?` is set. + +## Metrics to capture AFTER enabling + +1. **Spam rate** vs. baseline — the primary success metric. +2. **Share of submissions marked `turnstile`** — see the query above. +3. **Total legitimate volume** — did real submissions drop? A large fall may + indicate friction/abandonment rather than blocked bots. +4. **Blocked (failed) submissions** — not recorded in the app today; see gap + below. + +## Known measurement gap: blocked submissions + +The most valuable metric — how many submissions Turnstile actually blocked — is +not recorded anywhere. When verification fails, the submission is discarded with +no counter, no log line, and no persisted row. Without it you cannot distinguish +"Turnstile blocked many bots" from "Turnstile did nothing." + +Ways to close the gap: + +- **Cloudflare dashboard (no code):** Turnstile analytics on Cloudflare's side + show challenge / solve / fail counts. +- **Lightweight logging (low risk):** add a log line in the verification-failure + branch counting failures with the form id and IP. +- **Persisted attempt counter (schema change):** durable before/after data in + the app itself. + +## Interpreting the results + +| Observation | Likely interpretation | +|---|---| +| Spam down, legit volume steady | Turnstile is working — keep it | +| Spam down, legit volume also down noticeably | Friction may deter real users — investigate abandonment | +| Spam unchanged, few `turnstile`-stamped rows | Misconfiguration (keys/env) — verify before concluding it fails | +| Spam unchanged, many `turnstile`-stamped rows | Spam was not bot-driven — Turnstile is the wrong tool | + +## Controlling for confounders + +- Compare before/after on the **same form** over comparable-length windows. +- Watch for external changes (a shared link, seasonal traffic) that move volume + independently of Turnstile. diff --git a/spec/features/embedded_touchpoints_spec.rb b/spec/features/embedded_touchpoints_spec.rb index e84115c6f..9e54bea7b 100644 --- a/spec/features/embedded_touchpoints_spec.rb +++ b/spec/features/embedded_touchpoints_spec.rb @@ -7,7 +7,7 @@ let!(:user) { FactoryBot.create(:user, :admin, organization:) } context 'as Admin' do - let!(:form) { FactoryBot.create(:form, :kitchen_sink, organization:) } + let!(:form) { FactoryBot.create(:form, :kitchen_sink, organization:, enable_turnstile: true) } describe '/forms/:id/example' do before do