Bound the Turnstile retry loop and prove the production secret - #17
Merged
Merged
Conversation
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
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.
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.jsre-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,mainmade 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:
AbortSignal.timeoutnullor non-stringhostnamefails closed instead of raisingAttributeError(a 500)turnstile.outcomenow separateschallengedfromfail. Failures carry a closed-vocabularyreasonplus allowlisted Siteverifyerror_codes. Live Siteverify returnsinvalid-input-secretandmissing-input-secretwith HTTP 400, so error codes are read from non-2xx bodies; a non-2xx response still never passes.scripts/learner_report.pybreaks failures down by reason and code, and flags configuration alerts (bad or missing secret, missing site key).POST /__smoke/turnstileis 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 returnsvalid/invalid/testing_key/unverified/unexpected/absent. The secret is never included. WithPBE_SMOKE_BYPASS_SECRET,scripts/smoke_deployment.pyfails unless the secret works and a site key is configured, or Turnstile is off.invalid-input-responseas a working production secret. Onlymetadata.result_with_testing_keytells them apart, and the probe checks it.make deployruns 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 CIverifycheck passed on head544395awith Google Chrome, includingbrowser-layout-test. Locally, every step also passed except two focus-indicator checks inbrowser-layout-test("Search focus indicator is too weak", "CodeMirror focus indicator is too weak"). They fail the same way onmain(3f300af) in the sandbox's Playwright Chromium, as Upgrade Wrangler to 4.136.3 #14 also saw.scripts/format_examples.py --checkmake verify-python-version VERSION=3.13(109 examples)git diff --checkrunner.jsmain.py, including theAttributeErroron anullhostnamerejected+invalid-input-secret, andlearner_report.pyraised the configuration alert.hostname_mismatch, with no cookie and no code run.invalidfor a bogus secret,testing_keyfor both test secrets, andabsentwith no secret. A missing or wrong header got a 404, and the response is JSON withno-store.SKIPfor the probe when no bypass secret is set.The probe's
validpath is covered only by unit tests using Cloudflare's documented reply, because no production secret is available here. The first production smoke withPBE_SMOKE_BYPASS_SECRETwill confirm it: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):
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):
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/valuesand capture.runner-gridafter 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 indocs/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