Skip to content

fix(content): detect explicitly-rendered Turnstile managed challenge (TPS /InternalCaptcha)#7

Merged
DustinVK merged 3 commits into
mainfrom
fix/challenge-detection-managed
Jul 4, 2026
Merged

fix(content): detect explicitly-rendered Turnstile managed challenge (TPS /InternalCaptcha)#7
DustinVK merged 3 commits into
mainfrom
fix/challenge-detection-managed

Conversation

@DustinVK

@DustinVK DustinVK commented Jul 4, 2026

Copy link
Copy Markdown
Owner

What

detectChallenge() now returns true when the Cloudflare Turnstile API script (challenges.cloudflare.com/turnstile) is present in the top document, closing a gap where the TruePeopleSearch /InternalCaptcha managed challenge slipped past every existing heuristic and the sidebar showed verdict buttons over a live bot-gate.

Why it was missed

The gate renders Turnstile via turnstile.render(...) — so there is no .cf-turnstile container, and the widget iframes are about:blank (content injected cross-frame). The only reliable top-document signal is the Turnstile API <script>. Every prior selector (.cf-turnstile, iframe[src*="challenges.cloudflare.com"], the interstitial ids) keys off elements this shape doesn't expose.

Pre-existing gap, not a PR #6 regressionsrc/content/classify.ts is untouched by the tab-registry PR. Found during manual QA of that branch.

The fix (one line)

if (!turnstile && document.querySelector('script[src*="challenges.cloudflare.com/turnstile"]')) {
  return true;
}

Two non-obvious details, both locked by tests:

  • Host-specific match, not a substring. The same page hosts a cf.clym-widget.net iframe (a Clym consent widget, not Cloudflare — the cf. prefix is coincidental). A bare cf/cloudflare match would false-positive on it.
  • The !turnstile guard is load-bearing. Real solved Turnstile pages also carry the api.js script; without the guard, every solved container-Turnstile would falsely re-detect. The guard routes the container case to the existing solved/unsolved logic.

QA evidence (Firefox 140+)

  • Clean /results pages return null for this selector → no false-positive challenge over a real listing.
  • Solving /InternalCaptcha navigates away to the person page → the fresh load reports CHALLENGE_RESOLVED; no in-page solved-state detection needed.
  • Manual pass green: /InternalCaptcha → challenge view (Skip only) → solve → back to results, challenge key cleared, verdict view returns.

Scope — deliberately minimal

The human-in-the-loop plus Skip (no-wedge rule) is the backstop, so hardening for shapes no shipping broker exhibits is deferred:

  • Deferred: the unsolved-refinement (cf-turnstile-response check — inert on the callback-rendered TPS interstitial), the observer attributeFilter hardening (load-time detection already catches TPS), and the option-2 per-broker challenge.urlIncludes hint (schema + signed-dataset + CI cost, not yet needed).
  • Carried forward: an M9 per-broker challenge-resolve gate (plan/expurge-progress.md) — detection is generic and manifest-bounded, but resolve-via-navigation is proven n=1 (TPS). A future broker that resolves its gate inline needs a resolve signal before onboarding, or it would strand the challenge view over real results.

Tests

  • test(content): reproducing fixture (managed challenge → true) + two regression locks (solved-container-with-script → false for the guard; Clym-only → false for the host-specific selector).
  • Full suite green: 190 passed, tsc --noEmit clean, npm run build clean.

🤖 Generated with Claude Code

DustinVK and others added 3 commits July 3, 2026 22:49
Capture the /InternalCaptcha gap: an explicitly-rendered Cloudflare Turnstile
(turnstile.render(...)) exposes no .cf-turnstile container and about:blank widget
iframes, so detectChallenge() misses it and the sidebar shows verdict buttons over
a live bot-gate. The reproducing fixture (API script present, no container, no
response input -> true) is red; two regression locks ship green alongside it:

- solved container-Turnstile WITH the api.js script -> false, proving the coming
  `!turnstile` guard defers the container case to the existing solved/unsolved logic
  (real solved Turnstile pages carry the script too).
- Clym-consent-only (cf.clym-widget.net, no challenges.cloudflare.com script) ->
  false, locking the selector to the specific host so a bare "cf"/"cloudflare"
  substring can't false-positive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
detectChallenge() now returns true when the Turnstile API script
(challenges.cloudflare.com/turnstile) is present in the top document, catching
the TruePeopleSearch /InternalCaptcha managed challenge that renders Turnstile
via turnstile.render(...) — no .cf-turnstile container, about:blank widget
iframes — which every prior selector missed. The sidebar now shows the challenge
view instead of verdict buttons over a live bot-gate.

Match the specific challenges.cloudflare.com host only: the same page hosts a
cf.clym-widget.net iframe (Clym consent, not Cloudflare) that a bare
"cf"/"cloudflare" substring would false-positive on. The `!turnstile` guard is
load-bearing, not stylistic — real solved Turnstile pages also carry this script,
so without it every solved container-Turnstile would falsely re-detect; the guard
routes the container case to the existing solved/unsolved logic.

Verified against live QA: clean /results pages return null for this selector (no
false positive), and solving /InternalCaptcha navigates away, so the fresh load
reports CHALLENGE_RESOLVED with no in-page solved-state detection needed. Scoped
minimal by design — the human-in-the-loop plus Skip is the backstop, so the
unsolved-refinement and observer hardening are deferred until a broker whose gate
resolves inline actually needs them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
detectChallenge()'s Turnstile-script signal (added in this branch) is generic and
manifest-bounded, but its safety rests on the gate navigating away on solve — proven
on TruePeopleSearch only (n=1). Record the per-broker onboarding gate in the M9
milestone and a code-facing pointer in the TODO table, so a future broker that
resolves its gate inline gets a resolve signal before it can strand the challenge
view over real results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@DustinVK DustinVK merged commit 8ebbddf into main Jul 4, 2026
2 checks 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.

1 participant