Skip to content

Commit d8cf374

Browse files
committed
Match hidden CAPTCHA evidence precisely
1 parent d0f1610 commit d8cf374

3 files changed

Lines changed: 150 additions & 17 deletions

File tree

src/chrome/src/agent/agent.js

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2827,6 +2827,21 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
28272827
frameId: Number.isInteger(entry?.frameId) ? entry.frameId : 0,
28282828
payload: entry?.result,
28292829
}));
2830+
const hiddenChallenges = entries.flatMap(entry => {
2831+
const label = String(entry.payload?.hiddenChallenge?.label || '');
2832+
if (!label) return [];
2833+
const normalized = detectChallengeDialog(
2834+
`dialog ${JSON.stringify(label.slice(0, 200))}`,
2835+
{ allowGenericFailure: options?.allowGenericFailure === true },
2836+
);
2837+
if (!normalized?.normalizedLabel) return [];
2838+
return [{
2839+
frameId: entry.frameId,
2840+
frameUrl: String(entry.payload?.frameContext?.frameUrl || ''),
2841+
label: normalized.label,
2842+
normalizedLabel: normalized.normalizedLabel,
2843+
}];
2844+
});
28302845
const inspectedFrameIds = new Set(entries
28312846
.filter(entry => entry.payload && typeof entry.payload === 'object')
28322847
.map(entry => entry.frameId));
@@ -2836,9 +2851,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
28362851
return includeStatus
28372852
? {
28382853
challenge,
2839-
challengeHidden: entries.some(entry =>
2840-
entry.payload && typeof entry.payload === 'object' && entry.payload.hiddenChallenge?.label
2841-
),
2854+
challengeHidden: hiddenChallenges.length > 0,
2855+
hiddenChallenges,
28422856
// With no expected frame, "complete" still requires at least one
28432857
// frame to have actually been inspected.
28442858
inspectionComplete: expectedFrameId === null
@@ -2857,7 +2871,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
28572871
return inspected(results);
28582872
} catch {
28592873
return includeStatus
2860-
? { challenge: null, challengeHidden: false, inspectionComplete: false }
2874+
? {
2875+
challenge: null,
2876+
challengeHidden: false,
2877+
hiddenChallenges: [],
2878+
inspectionComplete: false,
2879+
}
28612880
: null;
28622881
}
28632882
}
@@ -2937,14 +2956,23 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
29372956
? recheck.challenge.frameId
29382957
: null;
29392958
observedChallengeFrameUrl = String(recheck.challenge.frameUrl || '');
2940-
} else if (recheck.inspectionComplete && recheck.challengeHidden) {
2959+
} else if (
2960+
recheck.inspectionComplete
2961+
&& recheck.hiddenChallenges?.some(hidden => (
2962+
hidden.frameId === (
2963+
Number.isInteger(observedChallengeFrameId)
2964+
? observedChallengeFrameId
2965+
: 0
2966+
)
2967+
&& hidden.normalizedLabel === challenge.normalizedLabel
2968+
))
2969+
) {
29412970
challenge = null;
29422971
}
2943-
// Only a completed scan that positively found the dialog hidden clears
2944-
// the tree's observation. "Not found" is not disproof — the DOM scan
2945-
// cannot see closed shadow roots or unreachable frames the tree can —
2946-
// and an inconclusive scan proves nothing; keep the challenge rather
2947-
// than failing the gate open.
2972+
// Only a completed scan that found the same dialog hidden in the same
2973+
// frame clears the tree observation. An unrelated hidden challenge,
2974+
// "not found", or an inconclusive scan cannot disprove a dialog in a
2975+
// closed shadow root or unreachable frame; keep the gate fail-closed.
29482976
}
29492977
let pageUrl = String(toolResult.currentUrl || toolResult.pageUrl || '');
29502978
if (!pageUrl) {

src/firefox/src/agent/agent.js

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2507,6 +2507,21 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
25072507
const successfulEntries = frameEntries.filter(entry =>
25082508
entry?.payload && typeof entry.payload === 'object'
25092509
);
2510+
const hiddenChallenges = successfulEntries.flatMap(entry => {
2511+
const label = String(entry.payload?.hiddenChallenge?.label || '');
2512+
if (!label) return [];
2513+
const normalized = detectChallengeDialog(
2514+
`dialog ${JSON.stringify(label.slice(0, 200))}`,
2515+
{ allowGenericFailure: options?.allowGenericFailure === true },
2516+
);
2517+
if (!normalized?.normalizedLabel) return [];
2518+
return [{
2519+
frameId: entry.frameId,
2520+
frameUrl: String(entry.payload?.frameContext?.frameUrl || ''),
2521+
label: normalized.label,
2522+
normalizedLabel: normalized.normalizedLabel,
2523+
}];
2524+
});
25102525
const challenge = this._visibleChallengeDialogFromFrames(
25112526
successfulEntries,
25122527
navigationFrames,
@@ -2517,7 +2532,8 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
25172532
&& navigationFrames.some(frame => frame?.frameId === expectedFrameId);
25182533
return {
25192534
challenge,
2520-
challengeHidden: successfulEntries.some(entry => entry.payload.hiddenChallenge?.label),
2535+
challengeHidden: hiddenChallenges.length > 0,
2536+
hiddenChallenges,
25212537
// With no expected frame, "complete" still requires at least one frame
25222538
// to have actually been inspected.
25232539
inspectionComplete: expectedFrameId === null
@@ -2601,14 +2617,23 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
26012617
? recheck.challenge.frameId
26022618
: null;
26032619
observedChallengeFrameUrl = String(recheck.challenge.frameUrl || '');
2604-
} else if (recheck.inspectionComplete && recheck.challengeHidden) {
2620+
} else if (
2621+
recheck.inspectionComplete
2622+
&& recheck.hiddenChallenges?.some(hidden => (
2623+
hidden.frameId === (
2624+
Number.isInteger(observedChallengeFrameId)
2625+
? observedChallengeFrameId
2626+
: 0
2627+
)
2628+
&& hidden.normalizedLabel === challenge.normalizedLabel
2629+
))
2630+
) {
26052631
challenge = null;
26062632
}
2607-
// Only a completed scan that positively found the dialog hidden clears
2608-
// the tree's observation. "Not found" is not disproof — the DOM scan
2609-
// cannot see closed shadow roots or unreachable frames the tree can —
2610-
// and an inconclusive scan proves nothing; keep the challenge rather
2611-
// than failing the gate open.
2633+
// Only a completed scan that found the same dialog hidden in the same
2634+
// frame clears the tree observation. An unrelated hidden challenge,
2635+
// "not found", or an inconclusive scan cannot disprove a dialog in a
2636+
// closed shadow root or unreachable frame; keep the gate fail-closed.
26122637
}
26132638
let pageUrl = String(toolResult.currentUrl || toolResult.pageUrl || '');
26142639
if (!pageUrl) {

test/run.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4885,6 +4885,86 @@ test('CAPTCHA visibility recheck sees shadow-rooted dialogs and keeps the gate o
48854885
assert.equal(agent._captchaGateStates.has(1), true, `${build}: shadow-rooted challenge did not persist a gate`);
48864886
});
48874887

4888+
// A hidden challenge is disproof only for the same normalized label in
4889+
// the same frame. An unrelated hidden reCAPTCHA must not clear a visible
4890+
// tree-only challenge (for example one inside a closed shadow root).
4891+
await withCaptchaFakePage(build, [
4892+
captchaEl('div', {
4893+
role: 'dialog',
4894+
innerText: 'reCAPTCHA',
4895+
hidden: true,
4896+
}),
4897+
], async () => {
4898+
const agent = new AgentClass({});
4899+
const recheck = await agent._detectChallengeDialogBeforeMutation(1, {
4900+
includeStatus: true,
4901+
});
4902+
assert.deepEqual(recheck.hiddenChallenges, [{
4903+
frameId: 0,
4904+
frameUrl: 'https://example.test/form',
4905+
label: 'reCAPTCHA',
4906+
normalizedLabel: 'recaptcha',
4907+
}], `${build}: hidden challenge evidence lost its frame or normalized label`);
4908+
4909+
const observation = await agent._observeCaptchaChallenge(
4910+
1,
4911+
'get_accessibility_tree',
4912+
{
4913+
pageContent: 'dialog "Security verification" [ref_1]\n button "Verify" [ref_2]',
4914+
pageUrl: 'https://example.test/form',
4915+
},
4916+
{},
4917+
);
4918+
assert.ok(observation.gate, `${build}: unrelated hidden challenge cleared the tree-only CAPTCHA gate`);
4919+
assert.equal(agent._captchaGateStates.has(1), true, `${build}: unrelated hidden challenge failed open`);
4920+
4921+
const matchingAgent = new AgentClass({});
4922+
const matchingObservation = await matchingAgent._observeCaptchaChallenge(
4923+
1,
4924+
'get_accessibility_tree',
4925+
{
4926+
pageContent: 'dialog "reCAPTCHA" [ref_1]\n button "Verify" [ref_2]',
4927+
pageUrl: 'https://example.test/form',
4928+
},
4929+
{},
4930+
);
4931+
assert.equal(
4932+
matchingObservation.gate,
4933+
null,
4934+
`${build}: matching hidden evidence did not clear the tree observation`,
4935+
);
4936+
assert.equal(
4937+
matchingAgent._captchaGateStates.has(1),
4938+
false,
4939+
`${build}: matching hidden evidence persisted a stale gate`,
4940+
);
4941+
});
4942+
4943+
const crossFrameAgent = new AgentClass({});
4944+
crossFrameAgent._detectChallengeDialogBeforeMutation = async () => ({
4945+
challenge: null,
4946+
challengeHidden: true,
4947+
hiddenChallenges: [{
4948+
frameId: 7,
4949+
frameUrl: 'https://challenge.example.test/hidden',
4950+
label: 'Security verification',
4951+
normalizedLabel: 'security verification',
4952+
}],
4953+
inspectionComplete: true,
4954+
});
4955+
const crossFrameObservation = await crossFrameAgent._observeCaptchaChallenge(
4956+
1,
4957+
'get_accessibility_tree',
4958+
{
4959+
pageContent: 'dialog "Security verification" [ref_1]\n button "Verify" [ref_2]',
4960+
pageUrl: 'https://example.test/form',
4961+
captchaChallengeFrameId: 0,
4962+
},
4963+
{},
4964+
);
4965+
assert.ok(crossFrameObservation.gate, `${build}: hidden challenge from another frame cleared the gate`);
4966+
assert.equal(crossFrameAgent._captchaGateStates.has(1), true, `${build}: cross-frame hidden challenge failed open`);
4967+
48884968
// Hidden ancestry must still win even across the shadow boundary: a
48894969
// challenge dialog inside the shadow root of a hidden host stays inert.
48904970
await withCaptchaFakePage(build, [

0 commit comments

Comments
 (0)