Skip to content

CAPTCHA gate is English-only: make challenge detection language-neutral #505

Description

@esokullu

Context

PR esokullu#204 ("recaptcha enterprise fixes") introduced src/{chrome,firefox}/src/agent/captcha-gate.js — a gate that blocks page-changing actions while a human-verification challenge is active. The CAPTCHA solving machinery (captcha-solver.js, captcha-frame-runtime.js) predates it; the gate is new.

The gate works and is well covered (~1,100 lines of regression tests). But every path that arms it matches English dialog copy, so it is inert on non-English pages. This issue records the language-neutral redesign.

Merging esokullu#204 was still the right call — English-only gating beats no gating. This is the follow-up to make it solid.

The problem

Three regexes in captcha-gate.js drive everything:

CHALLENGE_DIALOG_RE   // captcha | security verification | verify you are human | robot check | ...
CHALLENGE_FAILURE_RE  // verification failed | could not verify | ...
CHALLENGE_CONTEXT_RE  // captcha | human | robot | challenge

The same pattern is duplicated 8 times across 6 files (captcha-gate.js ×2 per build — once at module scope, once inline because the detector is serialized into the page — plus captcha-frame-runtime.js and tools.js, times Chrome and Firefox). There is no i18n infrastructure in the extension: no _locales, no default_locale in the manifest.

Consequence 1 — the gate never arms. _observeCaptchaChallenge opens with detectChallengeDialog(pageContent). No English match ⇒ { gate: null } ⇒ no block. The pageGate fallback runs the same regex on the label, and _captchaMutationPreflightdetectChallengeDialogInPage uses the inline copy. So on a German, Turkish, Japanese, or Spanish page the agent can click Continue/Submit straight into an active challenge — the exact loop the gate exists to prevent.

Partial reprieve: "CAPTCHA" is a loanword in many languages, so /\bcaptcha\b/i fires sometimes. But "Sicherheitsüberprüfung", "Güvenlik doğrulaması", "Vérification de sécurité" match nothing — and Cloudflare's "Verifying you are human" is localized per visitor.

Consequence 2 — routing fails too. solve_required requires selectedCorrelated, which requires detection.selected.dialogAssociated === true, which is computed in captcha-frame-runtime.js by testing dialog text against the same English regex. So even if arming were fixed, a non-English challenge would fall through to manual_required with candidateNotCorrelated: true.

Consequence 3 — the pattern is brittle even in English. Review rounds on esokullu#204 caught a mojibake ’ for the curly apostrophe and a miss on "verify you are a human" vs "verify you are human". A heuristic that breaks on an article and a character encoding within one language will not survive a hundred.

Note the tell: normalizeChallengeLabel uses \p{L}\p{N} with the u flag, so key derivation is Unicode-aware while the matching right above it is ASCII English.

The fix: invert trigger and confirmation

Today: English regex matches a label → arm gate → call detectCaptcha to check solvability.
Instead: detectCaptcha finds a visible vendor challenge → arm gate → consult the label only as extra context.

detectCaptcha is already language-neutral — it keys on iframe hosts, data-sitekey, and response-field names, all ASCII protocol surface that does not localize. captcha-frame-runtime.js already computes a challengeFrame flag from the frame URL, and the ranker already has a 'visible challenge frame' reason. The concept exists; it is merely downstream of the regex.

Step 1 — distinguish the challenge frame from the checkbox frame

This replaces dialogAssociated, and it is strictly more precise than the text test.

reCAPTCHA's checkbox is /recaptcha/{api2,enterprise}/anchor (already detected). The actual image-grid challenge is a separate iframe at /recaptcha/{api2,enterprise}/bframebframe currently appears nowhere in the codebase. A visible bframe means the challenge is blocking right now; an anchor frame alone means a widget merely exists on the page.

Same split for the other vendors: hCaptcha #frame=challenge vs #frame=checkbox; Arkose /fc/gc/ enforcement frame.

This one rule does the job that three review rounds tried to get out of text: separating "an invisible v3 badge exists somewhere" from "a challenge is in the user's face."

Step 2 — read the token back

Bigger win. responseField is currently only a boolean presence check; the value is never read. But g-recaptcha-response / h-captcha-response / cf-turnstile-response being non-empty is a direct readout of solve state.

All the plumbing exists: token injection already resolves the exact field via responseFieldId / responseFieldIndex / frame selection and does element.value = token. Reading it back is the same resolution with a get instead of a set.

Caveat: a token means the widget produced one, not that the server accepted it. If the server rejects and re-renders, the challenge frame becomes visible again and the gate re-arms via Step 1 — a cleaner loop than the current renamed-dialog handling, and correct for the right reason.

Step 3 — demote the regex to an additive trigger

Keep it for in-house challenges with no recognizable vendor frame, but make it additive-only: it may arm a gate, its absence may never clear one. A wording miss then costs a missed exotic case, not a safety hole — and the 8 copies can be consolidated into one exported constant without anxiety.

If real multilingual text coverage is wanted later, a phrase table (~25 languages × ~4 phrases covers most traffic) is the honest approach. Optional polish, not part of this fix.

What this deletes

Most of the flag soup exists to compensate for not having a direct readout of solve state — the runtime currently infers "did it work?" by asking the model to re-read the accessibility tree and checking whether a dialog vanished. With Step 2 the runtime just looks.

Retire: verificationAttempts, verificationRetryRequired, verificationReadRequired, verificationFrameReadRequired, solveFailedToClearChallenge, clearedByReadOnlyVerification, remainingDialogSurface, allowGenericFailure, the two-attempt retry ladder, and authoritativeRootRead with its six-condition definition of a "complete" read. The model no longer needs a qualifying root read to clear the gate.

Net change to source should be negative in lines.

Separate but related gap: full-page interstitials

Cloudflare's managed challenge replaces the whole document — there is no [role="dialog"], so the gate misses it in every language, English included. Language-neutral tells: /cdn-cgi/challenge-platform/ subresource requests, and the cf-mitigated: challenge response header. The webNavigation permission is already held. Probably more common in practice than a localized modal.

Cost

The expensive part is the ~1,100 lines of regression tests, which encode the current semantics — many need re-expressing, not just re-running. Do not discard them. The page shapes found during esokullu#204 review are valuable fixtures: hidden dialog inside a visible iframe; invisible Enterprise widget inside a modal; unrelated v3 loader alongside a passkey prompt. Keep the scenarios, rewrite the assertions against frame visibility and token state.

Suggested first step

Land Step 2 (read the token back) alone on a fresh branch. It is the smallest change, independently useful even if nothing else moves, and it immediately reveals how much of the flag machinery was only ever standing in for a value the page was willing to hand over.


Recorded after reviewing PR esokullu#204 at b9df6eca. Suite at that commit: 1323 passed, 1 failed — the failure (repository changelog retains complete, non-empty release history, package.json 25.9.2 vs newest changelog entry 25.9.0) is inherited from the base commit and unrelated.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions