From 3ff7b7a73b06ff7461b376fd1470677a47d871fd Mon Sep 17 00:00:00 2001 From: Brett Date: Mon, 25 May 2026 02:12:29 -0500 Subject: [PATCH 1/2] fix(client): drop invalid Turnstile size:'invisible', add execution:'execute' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acquireTurnstileToken passed `size: 'invisible'` to api.render(). Per CF docs, `size` accepts compact|flexible|normal only; 'invisible' is not a valid value and Turnstile throws "Uncaught TurnstileError: Invalid value for parameter 'size'". The widget enters a stuck state, and every subsequent execute() call reports "Call to execute() on a widget that is already executing" — masking the #119 reset+execute fix. Invisible behavior is sitekey-mode (set in the CF dashboard), not a render-time param. Drop the invalid `size` value; the off-screen container CSS (position:absolute;left:-9999px) keeps the widget visually hidden regardless of mode. Also add `execution: 'execute'` so the challenge defers to our explicit api.execute() call instead of starting on render (the default 'render' execution races acquireTurnstileToken's promise resolution). Type definition tightened to match docs: size enum now `'compact'|'flexible'|'normal'`, new optional `execution` field. Surfaced after #118 corrected the production sitekey and the form started producing real Turnstile validation. Pre-#118 the wrong sitekey errored before size validation; post-#118 the size error fires first. --- src/client/live-score.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/client/live-score.ts b/src/client/live-score.ts index 9203330..0d3e786 100644 --- a/src/client/live-score.ts +++ b/src/client/live-score.ts @@ -26,7 +26,16 @@ interface TurnstileApi { element: HTMLElement | string, options: { sitekey: string; - size?: 'invisible' | 'normal' | 'compact'; + // `size` is widget dimensions (compact|flexible|normal). `invisible` + // is NOT a valid size value — Turnstile throws on render. Invisible + // behavior is controlled by the sitekey's mode (set in the CF + // dashboard), not by render-time params. + size?: 'compact' | 'flexible' | 'normal'; + // `execution: 'execute'` defers the challenge until execute() is + // called explicitly (matches our acquire-on-submit flow). The + // default 'render' starts the challenge as soon as the widget + // mounts, which races our acquireTurnstileToken caller. + execution?: 'render' | 'execute'; callback?: (token: string) => void; 'error-callback'?: () => void; 'expired-callback'?: () => void; @@ -275,7 +284,7 @@ function acquireTurnstileToken( } const id = api.render(container, { sitekey, - size: 'invisible', + execution: 'execute', callback: (token: string) => settleTurnstile({ token }), 'error-callback': () => settleTurnstile({ error: new Error('turnstile_error') }), 'expired-callback': () => settleTurnstile({ error: new Error('turnstile_expired') }), From 1ff0b0162d1c6c51be8922f3f172bb0815c4cef5 Mon Sep 17 00:00:00 2001 From: Brett Date: Mon, 25 May 2026 02:23:23 -0500 Subject: [PATCH 2/2] fix: allow Lato in CSP + tear down Turnstile widget on pagehide Two follow-ups to the size:'invisible' fix (3ff7b7a), surfaced during staging validation: CSP for Turnstile's Lato stylesheet. Even with the sitekey configured as Invisible mode in the CF dashboard, the Turnstile bootstrap injects `` into the host document (defensive UI prep in case a challenge elevates to a visible UI). The CSS in turn loads font files from fonts.gstatic.com. Allowlist both origins on style-src + font-src in both CSP_HTML (src/worker/headers.ts) and LIVE_SCORE_CSP (src/worker/score/summary-render.ts). pagehide widget teardown. After the size fix the homepage form worked on first submit, but a back-button bfcache restore re-bootstrapped the existing Turnstile widget DOM and re-injected the Lato stylesheet, triggering the CSP violation again (and a "preload but not used" warning for the challenge platform's cmg resource). On pagehide, api.remove(widgetId) the existing widget and clear module-scope state; the next acquireTurnstileToken renders fresh. Validated on staging at Worker version c6ab5306-b238-4e07-b41b-472858261c15: first submit succeeds, back-button, second submit succeeds, zero CSP violations, zero Turnstile errors. --- src/client/live-score.ts | 12 ++++++++++++ src/worker/headers.ts | 10 ++++++++-- src/worker/score/summary-render.ts | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/client/live-score.ts b/src/client/live-score.ts index 0d3e786..95f3902 100644 --- a/src/client/live-score.ts +++ b/src/client/live-score.ts @@ -121,6 +121,18 @@ function initLiveScore(els: { // curated-reward or phase-progression text from the previous submit. // Reset to a clean state so the form is immediately usable again. // Standard a11y pattern, no copy change needed. + // + // Also tear down the Turnstile widget on pagehide so a bfcache-restored + // page doesn't reuse a half-dead widget instance. On the next + // acquireTurnstileToken the module-scope state is empty and a fresh + // widget is rendered. + window.addEventListener('pagehide', () => { + if (turnstileWidget && window.turnstile) { + window.turnstile.remove(turnstileWidget.id); + } + turnstileWidget = null; + pendingTurnstile = null; + }); window.addEventListener('pageshow', (event) => { if (!event.persisted) return; setSubmitting(els, false); diff --git a/src/worker/headers.ts b/src/worker/headers.ts index 2d00ace..eb7ac75 100644 --- a/src/worker/headers.ts +++ b/src/worker/headers.ts @@ -65,14 +65,20 @@ const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'; // // Applied to every HTML response (not just /), so a CSP regression test // hitting any page surfaces drift on every directive. +// style-src + font-src include the Google Fonts origins because the +// Turnstile widget bootstrap injects `` into the +// host document even when the sitekey is configured as Invisible mode +// in the CF dashboard (defensive UI prep in case the challenge elevates). +// The CSS file in turn loads font files from fonts.gstatic.com. const CSP_HTML = "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; " + 'frame-src https://challenges.cloudflare.com; ' + "connect-src 'self' https://challenges.cloudflare.com; " + "img-src 'self' data:; " + - "style-src 'self' 'unsafe-inline'; " + - "font-src 'self'; " + + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + + "font-src 'self' https://fonts.gstatic.com; " + "base-uri 'self'; " + "form-action 'self'; " + "object-src 'none'; " + diff --git a/src/worker/score/summary-render.ts b/src/worker/score/summary-render.ts index 2874d21..c722f6b 100644 --- a/src/worker/score/summary-render.ts +++ b/src/worker/score/summary-render.ts @@ -235,8 +235,8 @@ const LIVE_SCORE_CSP = 'frame-src https://challenges.cloudflare.com; ' + "connect-src 'self' https://challenges.cloudflare.com; " + "img-src 'self' data:; " + - "style-src 'self' 'unsafe-inline'; " + - "font-src 'self'; " + + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + + "font-src 'self' https://fonts.gstatic.com; " + "base-uri 'self'; " + "form-action 'self'; " + "object-src 'none'; " +