From 54398837e62865ef27aa36f78727c9077a4c9364 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 13 Apr 2026 17:23:41 -0700 Subject: [PATCH] Tighten browser inline overlay qualification --- browser/extension/content.js | 233 ++++++++++++++++++++++++++--- browser/extension/content.test.cjs | 78 +++++++++- 2 files changed, 286 insertions(+), 25 deletions(-) diff --git a/browser/extension/content.js b/browser/extension/content.js index e56b31a..cc09521 100644 --- a/browser/extension/content.js +++ b/browser/extension/content.js @@ -36,21 +36,138 @@ function normalizeRole(rawRole) { switch (String(rawRole || "").trim().toLowerCase()) { case "password": return "password"; - default: + case "username": return "username"; + default: + return ""; } } +function lowerJoined(values) { + return values + .map((value) => String(value || "").trim().toLowerCase()) + .filter(Boolean) + .join(" "); +} + +function fieldHintText(input) { + if (!input || typeof input !== "object") { + return ""; + } + const labels = input.labels ? Array.from(input.labels).map((label) => label.textContent || "") : []; + return lowerJoined([ + input.getAttribute?.("type"), + input.getAttribute?.("name"), + input.getAttribute?.("id"), + input.autocomplete, + input.getAttribute?.("autocomplete"), + input.getAttribute?.("placeholder"), + input.getAttribute?.("aria-label"), + ...labels + ]); +} + +function textLikeInputType(type) { + switch (String(type || "").toLowerCase()) { + case "": + case "text": + case "email": + case "tel": + case "number": + return true; + default: + return false; + } +} + +function hintMatches(text, patterns) { + return patterns.some((pattern) => pattern.test(text)); +} + +function scopeHintText(scope) { + const isDocumentScope = typeof document !== "undefined" && scope === document; + if ((!scope || typeof scope !== "object") || (!isDocumentScope && typeof scope.querySelectorAll !== "function" && typeof scope.getAttribute !== "function")) { + return ""; + } + const attrText = isDocumentScope ? "" : lowerJoined([ + scope.getAttribute?.("id"), + scope.getAttribute?.("name"), + scope.getAttribute?.("class"), + scope.getAttribute?.("action"), + scope.getAttribute?.("aria-label") + ]); + const headingText = lowerJoined(Array.from(scope.querySelectorAll?.("button, h1, h2, h3, h4, legend, label, [role='button']") || []) + .slice(0, 8) + .map((element) => element.textContent || "")); + return lowerJoined([attrText, headingText]); +} + +function hasAuthFlowSignals(usernameInput, scope) { + if (usernameInput) { + return true; + } + return hintMatches(scopeHintText(scope), authScopePatterns); +} + +const usernameHintPatterns = [ + /\buser(name|id)?\b/, + /\blog[\s_-]?in\b/, + /\bsign[\s_-]?in\b/, + /\bemail\b/, + /\be-mail\b/, + /\baccount\b/, + /\bmember\b/, + /\bidentifier\b/ +]; + +const nonLoginHintPatterns = [ + /\bsearch\b/, + /\bquery\b/, + /\bfilter\b/, + /\bcomment\b/, + /\bmessage\b/, + /\bcontact\b/, + /\bcity\b/, + /\bstate\b/, + /\bpostal\b/, + /\bzip\b/, + /\bcoupon\b/, + /\bpromo\b/, + /\bnewsletter\b/, + /\bsubscribe\b/ +]; + +const authScopePatterns = [ + /\blog[\s_-]?in\b/, + /\bsign[\s_-]?in\b/, + /\bauth\b/, + /\bpassword\b/, + /\bpasscode\b/, + /\b2fa\b/, + /\btwo[\s-]?factor\b/, + /\bverify\b/, + /\baccount\b/ +]; + function describeFieldRole(input) { const type = String(input?.getAttribute?.("type") || "").toLowerCase(); if (type === "password") { return "password"; } - const autocomplete = String(input?.autocomplete || "").toLowerCase(); - if (autocomplete.includes("username") || autocomplete.includes("email")) { + if (!textLikeInputType(type)) { + return ""; + } + const hints = fieldHintText(input); + if (!hints) { + return ""; + } + if (hintMatches(hints, nonLoginHintPatterns)) { + return ""; + } + if (hintMatches(hints, usernameHintPatterns)) { return "username"; } - return "username"; + return ""; } function isUsernameCandidate(input) { @@ -102,6 +219,40 @@ function firstVisibleUsername(scope) { return visibleInputs(scope).find(isUsernameCandidate) || null; } +function authFlowCandidate(anchorInput) { + const scope = (typeof HTMLFormElement !== "undefined" && anchorInput?.form instanceof HTMLFormElement ? anchorInput.form : document); + const scopeInputs = resolveFormInputs(anchorInput); + const passwordInput = scopeInputs.find(isPasswordCandidate) || null; + if (!passwordInput) { + return null; + } + const associated = associatedFieldsForAnchor(anchorInput || passwordInput); + if (!hasAuthFlowSignals(associated.usernameInput, scope)) { + return null; + } + return { + usernameInput: associated.usernameInput, + passwordInput, + anchorInput: anchorInput || passwordInput, + scope + }; +} + +function loginCandidates() { + const candidates = []; + for (const passwordInput of visibleInputs(document).filter(isPasswordCandidate)) { + const candidate = authFlowCandidate(passwordInput); + if (!candidate) { + continue; + } + if (candidates.some((existing) => existing.passwordInput === candidate.passwordInput)) { + continue; + } + candidates.push(candidate); + } + return candidates; +} + function associatedFieldsForAnchor(anchorInput) { const scopeInputs = resolveFormInputs(anchorInput); const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document); @@ -121,11 +272,11 @@ function associatedFieldsForAnchor(anchorInput) { } function buildFieldDescriptor(input, role) { - if (!(input instanceof HTMLInputElement)) { + if (typeof HTMLInputElement === "undefined" || !(input instanceof HTMLInputElement)) { return null; } const normalizedRole = normalizeRole(role || describeFieldRole(input)); - const form = input.form instanceof HTMLFormElement ? input.form : null; + const form = typeof HTMLFormElement !== "undefined" && input.form instanceof HTMLFormElement ? input.form : null; const scope = form || document; const inputs = visibleInputs(scope); const fieldIndex = inputs.indexOf(input); @@ -145,6 +296,9 @@ function resolveFieldDescriptor(descriptor) { return null; } const normalizedRole = normalizeRole(descriptor.role); + if (!normalizedRole) { + return null; + } const forms = Array.from(document.forms || []); const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null; const scope = form || document; @@ -200,20 +354,22 @@ function chooseFillTargets(targetDescriptor) { function scanLoginFields() { const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null; const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null; - const targets = chooseFillTargets(buildFieldDescriptor(activeUsable, describeFieldRole(activeUsable))); - const anchorInput = activeUsable || targets.passwordInput || targets.usernameInput; - const focusTarget = buildFieldDescriptor(anchorInput, describeFieldRole(anchorInput)); - const allVisible = visibleInputs(document); - const roles = allVisible - .filter((input) => isUsernameCandidate(input) || isPasswordCandidate(input)) - .map((input) => { - const descriptor = buildFieldDescriptor(input, describeFieldRole(input)); - return `${descriptor.formIndex}:${descriptor.fieldIndex}:${descriptor.role}`; - }); + const explicitRole = describeFieldRole(activeUsable); + const activeTargets = activeUsable ? authFlowCandidate(activeUsable) : null; + const candidates = loginCandidates(); + const chosen = activeTargets || candidates[0] || null; + const anchorInput = activeUsable || chosen?.passwordInput || chosen?.usernameInput || null; + const focusRole = explicitRole || describeFieldRole(anchorInput); + const focusTarget = anchorInput ? buildFieldDescriptor(anchorInput, focusRole) : null; + const roles = candidates.map((candidate) => { + const passwordDescriptor = buildFieldDescriptor(candidate.passwordInput, "password"); + const usernameDescriptor = candidate.usernameInput ? buildFieldDescriptor(candidate.usernameInput, "username") : null; + return `${passwordDescriptor.formIndex}:${passwordDescriptor.fieldIndex}:password:${usernameDescriptor ? `${usernameDescriptor.formIndex}:${usernameDescriptor.fieldIndex}` : "-"}`; + }); return { - pageHasLoginForm: Boolean(targets.usernameInput || targets.passwordInput), - usernameInput: targets.usernameInput, - passwordInput: targets.passwordInput, + pageHasLoginForm: Boolean(chosen), + usernameInput: chosen?.usernameInput || null, + passwordInput: chosen?.passwordInput || null, anchorInput, focusTarget, signature: roles.join("|") @@ -265,8 +421,8 @@ function inlineMatchSummary(match) { return parts.join(" ยท ") || "No username"; } -function shouldShowInlineOverlay(state, hasTarget, suppressed) { - if (suppressed || !hasTarget) { +function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) { + if (suppressed || idleHidden || !hasTarget) { return false; } return Boolean( @@ -286,7 +442,11 @@ const contentTestExports = { chooseFillTargets, inlineMatchSummary, domainLabel, - shouldShowInlineOverlay + shouldShowInlineOverlay, + fieldHintText, + scopeHintText, + hasAuthFlowSignals, + authFlowCandidate }; if (isNodeTestEnv) { @@ -303,7 +463,9 @@ if (isNodeTestEnv) { }; let chooserOpen = false; let inlineSuppressed = false; + let inlineIdleHidden = false; let refreshTimer = null; + let idleHideTimer = null; let lastReportedSignature = ""; let lastReportedTarget = ""; @@ -464,6 +626,24 @@ if (isNodeTestEnv) { dock.dataset.open = "false"; } + function clearIdleHideTimer() { + if (idleHideTimer !== null) { + clearTimeout(idleHideTimer); + idleHideTimer = null; + } + } + + function refreshInlineLifetime(shouldShow) { + clearIdleHideTimer(); + if (!shouldShow || chooserOpen || pageState.pendingFill) { + return; + } + idleHideTimer = window.setTimeout(() => { + inlineIdleHidden = true; + hideDock(); + }, 15000); + } + function positionDock() { const anchor = currentTarget(); if (!anchor || dock.style.display === "none") { @@ -537,9 +717,10 @@ if (isNodeTestEnv) { function renderInlineState() { const target = currentTarget(); - const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed); + const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed, inlineIdleHidden); if (!shouldShow) { + clearIdleHideTimer(); hideDock(); return; } @@ -558,17 +739,21 @@ if (isNodeTestEnv) { dock.dataset.open = chooserOpen ? "true" : "false"; renderMatches(); positionDock(); + refreshInlineLifetime(shouldShow); } function reportFieldState(force) { const scan = scanLoginFields(); + const nextTarget = JSON.stringify(scan.focusTarget || null); + if (scan.signature !== lastReportedSignature || nextTarget !== lastReportedTarget) { + inlineIdleHidden = false; + } pageState = { ...pageState, pageHasLoginForm: scan.pageHasLoginForm, focusTarget: scan.focusTarget }; renderInlineState(); - const nextTarget = JSON.stringify(scan.focusTarget || null); if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) { return; } diff --git a/browser/extension/content.test.cjs b/browser/extension/content.test.cjs index bffc359..ecee94d 100644 --- a/browser/extension/content.test.cjs +++ b/browser/extension/content.test.cjs @@ -18,6 +18,68 @@ test("domainLabel tolerates invalid URLs", () => { assert.equal(content.domainLabel("not-a-url"), ""); }); +test("describeFieldRole only treats explicit account fields as usernames", () => { + const loginField = { + autocomplete: "username", + labels: [], + getAttribute(name) { + const attrs = { + type: "email", + id: "crew-email", + name: "email", + placeholder: "Email address", + "aria-label": "Email address" + }; + return attrs[name] || ""; + } + }; + const searchField = { + autocomplete: "", + labels: [], + getAttribute(name) { + const attrs = { + type: "text", + id: "site-search", + name: "query", + placeholder: "Search casino news", + "aria-label": "Search" + }; + return attrs[name] || ""; + } + }; + + assert.equal(content.describeFieldRole(loginField), "username"); + assert.equal(content.describeFieldRole(searchField), ""); +}); + +test("hasAuthFlowSignals rejects generic password scopes and accepts sign-in scopes", () => { + const genericScope = { + getAttribute() { + return ""; + }, + querySelectorAll() { + return [{ textContent: "Confirm shipment" }]; + } + }; + const signInScope = { + getAttribute(name) { + const attrs = { + id: "signin-panel", + name: "signin", + action: "/session" + }; + return attrs[name] || ""; + }, + querySelectorAll() { + return [{ textContent: "Sign in to the Bellagio vault" }]; + } + }; + + assert.equal(content.hasAuthFlowSignals(null, genericScope), false); + assert.equal(content.hasAuthFlowSignals(null, signInScope), true); + assert.equal(content.hasAuthFlowSignals({ id: "danny-ocean" }, genericScope), true); +}); + test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => { const state = { pageHasLoginForm: true, @@ -29,5 +91,19 @@ test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () }; assert.equal(content.shouldShowInlineOverlay(state, true, false), true); - assert.equal(content.shouldShowInlineOverlay(state, true, true), false); + assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false); +}); + +test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => { + const state = { + pageHasLoginForm: true, + configured: true, + success: true, + status: { locked: false }, + matches: [{ id: "rusty-ryan" }], + pendingFill: false + }; + + assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true); + assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false); }); -- 2.52.0