Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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

209 changes: 189 additions & 20 deletions app/views/components/widget/_fba.js.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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? %>
Expand Down Expand Up @@ -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;
}
});
},
Expand Down
3 changes: 3 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions config/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
93 changes: 93 additions & 0 deletions docs/turnstile-metrics.md
Original file line number Diff line number Diff line change
@@ -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 = <id> AND created_at >= '<enable_date>'
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.
2 changes: 1 addition & 1 deletion spec/features/embedded_touchpoints_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down