Target the captcha widget the open modal is actually showing

Second reset still failed: token solved, callback reported as fired, confirm
button never enabled. The button enabling is pure client-side state — the
site's callback storing the token — so if it stays dark the LIVE callback
never ran, whatever was reported. Picking the newest client was still a guess.

There is a precise link. reCAPTCHA numbers its response fields by widget id:
the first widget gets #g-recaptcha-response, the second
#g-recaptcha-response-1. The field the open modal is showing therefore names
the client holding its callback. Use that, fall back to newest-first when
there is no id to go on.

The run log now prints which field carried the token and which widgets were on
the page, so the next failure is diagnosable from the log instead of by
hypothesis: `filled 1 response field(s) ['g-recaptcha-response-1'], fired
grecaptcha callback (widget 1)` / `page had widgets ['0','1'], live one looked
like 1`.

Also surfaces the extension version on the AutoBuyer page. The extension has
always stamped it into every capture and nothing ever read it, so the README's
claim that a version mismatch shows there was no way to check. Chrome runs the
old service worker until an unpacked extension is reloaded by hand, which is
exactly the state that makes a deployed fix look like a broken one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-31 01:46:31 -05:00
co-authored by Claude Opus 5
parent 2d53639c82
commit dfa706c7b7
3 changed files with 75 additions and 22 deletions
+9
View File
@@ -23,6 +23,10 @@ interface ViewportMetrics {
screenY: number; screenY: number;
chromeHeight: number; chromeHeight: number;
devicePixelRatio: number; devicePixelRatio: number;
/** Stamped by the extension. Chrome keeps running the old service worker
* until an unpacked extension is reloaded by hand, so this is the only way
* to tell from here whether a reload actually took. */
extVersion: string;
} }
interface Capture { interface Capture {
@@ -452,6 +456,11 @@ export default function AutoBuyer() {
<span className="text-sm font-medium text-slate-800">{capture.title || '(untitled)'}</span> <span className="text-sm font-medium text-slate-800">{capture.title || '(untitled)'}</span>
<span className="text-xs font-mono text-slate-400 break-all">{capture.url}</span> <span className="text-xs font-mono text-slate-400 break-all">{capture.url}</span>
<span className="ml-auto flex items-center gap-3 text-xs text-slate-400"> <span className="ml-auto flex items-center gap-3 text-xs text-slate-400">
{capture.viewport?.extVersion && (
<span title="Extension version that sent this capture">
ext v{capture.viewport.extVersion}
</span>
)}
<span>{(capture.html.length / 1024).toFixed(1)} KB</span> <span>{(capture.html.length / 1024).toFixed(1)} KB</span>
<span className={stale ? 'text-amber-600' : ''}> <span className={stale ? 'text-amber-600' : ''}>
{new Date(capture.capturedAt).toLocaleTimeString()} {new Date(capture.capturedAt).toLocaleTimeString()}
+10 -3
View File
@@ -152,10 +152,17 @@ def solve(dash, step, opts, report, cancelled=None) -> None:
}, PAGE_TIMEOUT) }, PAGE_TIMEOUT)
called = delivered.get("called") called = delivered.get("called")
seen = delivered.get("candidates") or 0 fields = delivered.get("fieldIds") or []
keys = delivered.get("clientKeys") or []
report(f"filled {delivered.get('filled', 0)} response field(s)" report(f"filled {delivered.get('filled', 0)} response field(s)"
+ (f", fired {called}" if called else "") + (f" {fields}" if fields else "")
+ (f" — {seen} widgets on the page, used the newest" if seen > 1 else "")) + (f", fired {called}" if called else ""))
# Printed whenever the page is carrying more than one widget, because that is
# the state in which handing the token to the wrong one is possible at all —
# and it looks identical to success right up until the button stays dark.
if len(keys) > 1 or (delivered.get("candidates") or 0) > 1:
report(f"page had widgets {keys or '?'}, live one looked like "
f"{delivered.get('liveWidgetId')}")
if not called: if not called:
# The token is in the form, so a plain submit still works — but a page # The token is in the form, so a plain submit still works — but a page
# that gates its submit button on the callback will look unsolved, and # that gates its submit button on the callback will look unsolved, and
+47 -10
View File
@@ -475,13 +475,30 @@ function pageCaptchaSubmit(token, provider) {
document.querySelectorAll('input[name^="cf-chl-widget-"]').forEach((el) => targets.add(el)); document.querySelectorAll('input[name^="cf-chl-widget-"]').forEach((el) => targets.add(el));
let filled = 0; let filled = 0;
const fieldIds = [];
for (const el of targets) { for (const el of targets) {
el.value = token; el.value = token;
el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));
fieldIds.push(el.id || `(${el.getAttribute('name')})`);
filled++; filled++;
} }
// reCAPTCHA numbers its response fields by widget id: the first widget gets
// #g-recaptcha-response, the second #g-recaptcha-response-1, and so on. That
// suffix is the only reliable link from the field the open modal is actually
// showing back to the client object holding ITS callback.
//
// Guessing wrong is invisible from the outside and looks exactly like the
// site ignoring a good token: the callback runs, but on a widget belonging
// to a modal that has already closed, so nothing stores the token and the
// confirm button never enables.
let liveWidgetId = null;
for (const el of targets) {
const match = /^g-recaptcha-response(?:-(\d+))?$/.exec(el.id || '');
if (match) { liveWidgetId = Number(match[1] || 0); break; }
}
let called = null; let called = null;
const invoke = (fn, where) => { const invoke = (fn, where) => {
if (called || typeof fn !== 'function') return false; if (called || typeof fn !== 'function') return false;
@@ -503,6 +520,7 @@ function pageCaptchaSubmit(token, provider) {
// from the outside like the site simply ignoring a correct token. // from the outside like the site simply ignoring a correct token.
const hookWidgets = [...(window.__autofirmerCaptcha?.widgets || [])].reverse(); const hookWidgets = [...(window.__autofirmerCaptcha?.widgets || [])].reverse();
let candidates = 0; let candidates = 0;
let clientKeys = [];
for (const widget of hookWidgets) { for (const widget of hookWidgets) {
if (provider && provider !== 'auto' && widget.kind !== provider) continue; if (provider && provider !== 'auto' && widget.kind !== provider) continue;
candidates++; candidates++;
@@ -523,29 +541,37 @@ function pageCaptchaSubmit(token, provider) {
// object and nowhere else. The keys around it are minified; `callback` is not. // object and nowhere else. The keys around it are minified; `callback` is not.
if (window.___grecaptcha_cfg?.clients) { if (window.___grecaptcha_cfg?.clients) {
// Clients accumulate: reCAPTCHA adds one per render and removes none, so // Clients accumulate: reCAPTCHA adds one per render and removes none, so
// after the second modal there are two and only the later one is live. // after the second modal there are two and only one is live. Try the one
// Highest key first. // the response field named, then the rest newest-first as a fallback.
const clients = Object.entries(window.___grecaptcha_cfg.clients) const all = Object.entries(window.___grecaptcha_cfg.clients);
.sort(([a], [b]) => Number(b) - Number(a)) clientKeys = all.map(([key]) => key);
.map(([, client]) => client); candidates += all.length;
candidates += clients.length;
const ordered = all
.sort(([a], [b]) => Number(b) - Number(a))
.sort(([a], [b]) => (Number(a) === liveWidgetId ? -1 : Number(b) === liveWidgetId ? 1 : 0));
for (const [key, client] of ordered) {
if (called) break;
// Depth-limited walk: the keys around it are minified, `callback`
// is not.
const seen = new Set(); const seen = new Set();
const queue = called ? [] : clients; const queue = [client];
let budget = 2000; let budget = 2000;
while (queue.length && !called && budget-- > 0) { while (queue.length && !called && budget-- > 0) {
const node = queue.shift(); const node = queue.shift();
if (!node || typeof node !== 'object' || seen.has(node)) continue; if (!node || typeof node !== 'object' || seen.has(node)) continue;
seen.add(node); seen.add(node);
for (const [key, value] of Object.entries(node)) { for (const [prop, value] of Object.entries(node)) {
if (typeof value === 'function') { if (typeof value === 'function') {
if (/callback/i.test(key) && invoke(value, `grecaptcha ${key}`)) break; if (/callback/i.test(prop) && invoke(value, `grecaptcha ${prop} (widget ${key})`)) break;
} else if (value && typeof value === 'object') { } else if (value && typeof value === 'object') {
queue.push(value); queue.push(value);
} }
} }
} }
} }
}
if (!filled && !called) { if (!filled && !called) {
return { error: 'nowhere to put the token — no response field and no callback on the page' }; return { error: 'nowhere to put the token — no response field and no callback on the page' };
@@ -553,7 +579,18 @@ function pageCaptchaSubmit(token, provider) {
// `candidates` is the diagnostic that matters when a token is accepted and // `candidates` is the diagnostic that matters when a token is accepted and
// the page does nothing: more than one means the page has stale widgets, and // the page does nothing: more than one means the page has stale widgets, and
// the token went to whichever this picked. // the token went to whichever this picked.
return { filled, called, candidates, provider, url: location.href }; return {
filled,
called,
candidates,
// The diagnostic that matters when a token is accepted and the page does
// nothing: which field carried it, and which widgets were on offer.
fieldIds,
liveWidgetId,
clientKeys,
provider,
url: location.href,
};
} }
async function resolveLocateTab(cfg, request) { async function resolveLocateTab(cfg, request) {