Skip to content

Bound the Turnstile retry loop and prove the production secret - #17

Merged
adewale merged 4 commits into
mainfrom
claude/turnstile-spin-learnings-vxs5gq
Sep 27, 2026
Merged

adewale merged 4 commits into
mainfrom
claude/turnstile-spin-learnings-vxs5gq

Conversation

@adewale

@adewale adewale commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Summary

I compared the runner's Turnstile protection with the canonical Siteverify handler in Cloudflare's Turnstile Spin skill. The comparison turned up a runaway loop and a blind spot, and each makes the other worse.

The loop. runner.js re-challenged every time a response carried the challenge marker, including right after it had just sent a token. So if Siteverify kept rejecting tokens (wrong secret, hostname, or action), one Run click became an endless cycle of widget solves, Worker POSTs, and Siteverify calls. With the real widget, main made 12 POSTs and 11 solves in 20 seconds from a single click, and was still going. Each Run now gets at most one challenge. If the retried token is rejected, the run ends with "Turnstile verification failed. Press Run to try again."

The blind spot. Deployment smoke skips Turnstile with the bypass header, and the wide event recorded both "challenge issued" and "token rejected" as fail. So a wrong, rotated, or test secret would pass smoke while every new session's first run failed.

What changed:

  • Runner: one challenge per Run. The browser check drives a stub server that always rejects. It fails on the old runner (20 POSTs, 19 solves) and passes on the new one (2 POSTs, 1 solve).
  • Siteverify hardening:
    • a 10-second AbortSignal.timeout
    • tokens over the documented 2048 characters are rejected without calling Siteverify
    • a null or non-string hostname fails closed instead of raising AttributeError (a 500)
    • the call is one helper, shared by learner verification and the new probe
  • Observability: turnstile.outcome now separates challenged from fail. Failures carry a closed-vocabulary reason plus allowlisted Siteverify error_codes. Live Siteverify returns invalid-input-secret and missing-input-secret with HTTP 400, so error codes are read from non-2xx bodies; a non-2xx response still never passes. scripts/learner_report.py breaks failures down by reason and code, and flags configuration alerts (bad or missing secret, missing site key).
  • Deploy-time secret probe: POST /__smoke/turnstile is gated by the existing smoke header and returns 404 without it. It sends the Worker's secret to Siteverify with Cloudflare's documented dummy token (production secrets reject it) and returns valid / invalid / testing_key / unverified / unexpected / absent. The secret is never included. With PBE_SMOKE_BYPASS_SECRET, scripts/smoke_deployment.py fails unless the secret works and a site key is configured, or Turnstile is off.
  • Test-secret detection: Cloudflare's always-fail test secret answers the dummy token with the same invalid-input-response as a working production secret. Only metadata.result_with_testing_key tells them apart, and the probe checks it.
  • Docs: updates to the runner-protection spec, observability spec, learner-analytics guide, and changelog, plus four lessons-learned entries. The fourth notes that make deploy runs the project-pinned Wrangler with account credentials, now that Dependabot proposes weekly npm updates.

Two things the probe cannot prove: that the secret and site key belong to the same widget, and that the widget allows the production hostname. A browser run still covers those. A production deploy with no secret at all reports Turnstile as off and passes smoke.

Verification

  • make verify: the CI verify check passed on head 544395a with Google Chrome, including browser-layout-test. Locally, every step also passed except two focus-indicator checks in browser-layout-test ("Search focus indicator is too weak", "CodeMirror focus indicator is too weak"). They fail the same way on main (3f300af) in the sandbox's Playwright Chromium, as Upgrade Wrangler to 4.136.3 #14 also saw.
  • scripts/format_examples.py --check
  • make verify-python-version VERSION=3.13 (109 examples)
  • git diff --check
  • Added or updated regression tests and verified they fail when the fix is reverted:
    • the rejection fixture fails on the old runner.js
    • the new Siteverify tests fail against the original main.py, including the AttributeError on a null hostname
  • Manual verification, on local Workers using Cloudflare's public test keys and live Siteverify:
    • Rejection loop: the before/after captures below. The before run shows the loop continuing.
    • Bogus secret: logged as rejected + invalid-input-secret, and learner_report.py raised the configuration alert.
    • Always-pass test secret, runner path: failed closed on hostname_mismatch, with no cookie and no code run.
    • Probe: reported invalid for a bogus secret, testing_key for both test secrets, and absent with no secret. A missing or wrong header got a 404, and the response is JSON with no-store.
    • Full smoke script: passes locally and prints SKIP for the probe when no bypass secret is set.

The probe's valid path is covered only by unit tests using Cloudflare's documented reply, because no production secret is available here. The first production smoke with PBE_SMOKE_BYPASS_SECRET will confirm it:

PBE_SMOKE_BYPASS_SECRET=... scripts/smoke_deployment.py https://www.pythonbyexample.dev

Visual evidence (required for UI changes)

Before — one Run click keeps re-challenging and stays on "Verification required…" (12 POSTs and 11 real solves after 20 s, still growing):

Before: runner stuck on Verification required while it loops

Open the before capture

After — one solve, two POSTs, then the server's message and a free Run button (still two POSTs after 20 s):

After: Run failed: Turnstile verification failed. Press Run to try again.

Open the after capture

Reproduction: uv run --group workers pywrangler dev --port <port> --var TURNSTILE_SECRET_KEY:2x0000000000000000000000000000000AA --var TURNSTILE_SITE_KEY:1x00000000000000000000BB, then click Run once on /examples/values and capture .runner-grid after six seconds (base: 3f300af; head: eae0dc3; viewport: 1200×900, light scheme; real invisible test widget with the always-fail test secret). Details and SHA-256 hashes are in docs/pr-evidence/README.md.

Example changes

Not applicable: no files under src/example_sources/ changed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PTpdP6DbuVU4VPLRzjNYGu

Comparing the runner protection with the canonical Siteverify handler in
Cloudflare's Turnstile Spin skill turned up an unbounded loop and a
monitoring blind spot that make each other worse.

- runner.js re-challenged whenever a response carried the challenge
  marker, including right after sending a token. A Siteverify that kept
  rejecting tokens (wrong secret, hostname, or action) turned one Run
  into endless solves, Worker POSTs, and Siteverify calls. Each Run now
  earns at most one challenge. When the retried token is rejected, the
  run ends with the server's message.
- Siteverify calls use a 10-second AbortSignal.timeout. Tokens over the
  documented 2048 characters are rejected without a subrequest. A
  missing or non-string hostname fails closed instead of raising a 500.
- The wide event now records "challenged" apart from "fail". Failures
  carry a closed-vocabulary reason and allowlisted Siteverify
  error_codes. Siteverify returns invalid-input-secret with HTTP 400, so
  error codes are read from non-2xx bodies; a non-2xx never passes.
- learner_report.py breaks failures down by reason and code and raises
  a configuration alert for a wrong or missing secret or site key.
  Deployment smoke cannot catch these because it uses the bypass header.

The browser check now drives a stub server that always rejects. It
fails on the old runner (20 POSTs, 19 solves for one Run) and passes
on the new one (2 POSTs, 1 solve).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTpdP6DbuVU4VPLRzjNYGu
Document the challenge/fail split, failure reasons, and error codes in
the observability spec and learner-analytics guide. Add the one-
challenge-per-Run rule and the reference-contract checks to the
runner-protection spec. Record four lessons: bound self-re-entering
retries; give a bypassed check its own signal, verified against the
live service; check hand-rolled security code against the vendor's
reference contract; and treat credential-bearing Wrangler updates as
supply-chain changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTpdP6DbuVU4VPLRzjNYGu
Smoke skips Turnstile with the bypass header, so it never touched
Siteverify. A wrong, rotated, or test secret passed smoke while every
new session's first run failed.

The Worker now exposes POST /__smoke/turnstile, gated by the same
header and answering 404 without it. The route sends the configured
secret to Siteverify with Cloudflare's documented dummy token, which
production secrets reject, and reports the result without the secret:

- valid: invalid-input-response, HTTP 200, no testing metadata
- invalid: invalid-input-secret or missing-input-secret (HTTP 400)
- testing_key: metadata.result_with_testing_key, or success on the
  dummy token. The always-fail test secret answers exactly like a
  production secret; only this flag tells them apart.
- unverified, unexpected, or absent (Turnstile off)

With PBE_SMOKE_BYPASS_SECRET, scripts/smoke_deployment.py calls the
probe. It fails unless the secret is a working production secret with
a configured site key, or Turnstile is off. Without the secret, it
prints SKIP. The Siteverify call now lives in one helper shared by
learner verification and the probe.

A local Worker returned invalid, testing_key (always-fail and
always-pass test secrets), and absent as expected. The valid path is
covered by unit tests using Cloudflare's documented reply, since no
production secret is available here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTpdP6DbuVU4VPLRzjNYGu
Capture /examples/values after one Run click on origin/main and on this
branch. Both Workers used Cloudflare's invisible always-pass test site
key, so the real widget solved in the browser, and the always-fail test
secret, so Siteverify rejected every token. main kept re-challenging
(12 POSTs and 11 solves in 20 seconds); the branch stops after one
solve with the server's message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTpdP6DbuVU4VPLRzjNYGu
@adewale
adewale merged commit 888e168 into main Sep 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants