diff --git a/extension/content/content.js b/extension/content/content.js index c1882af..34c795b 100644 --- a/extension/content/content.js +++ b/extension/content/content.js @@ -18,6 +18,10 @@ const PK_DROPDOWN_ID = "__pk_dropdown__"; const VAULT_URL = "https://pwkeeper.ngodanguyen.tech/vault"; + // Never inject on the PassKeeper vault itself — our own inputs would get decorated. + const OWN_ORIGINS = ["pwkeeper.ngodanguyen.tech"]; + if (OWN_ORIGINS.includes(location.hostname)) return; + let _bannerEl = null; let _hasNotifiedForm = false; let _formObserver = null; @@ -107,7 +111,41 @@ el.getAttribute("aria-label") || "", ].join(" "); - return CRED_HINTS.test(attrs); + if (!CRED_HINTS.test(attrs)) return false; + + // Final gate: require a password field to be nearby (same form, or within + // 5 ancestor levels) — this prevents hooking standalone search / filter + // inputs that happen to carry a name like "user" or "email". + return _hasPasswordSibling(el); + } + + /** + * Returns true when `el` shares a form (or close ancestor) with at least one + * visible password input. This is the key signal that we are on a login form, + * not a generic site-search or profile page. + */ + function _hasPasswordSibling(el) { + // 1. Prefer the explicit
ancestor. + const form = + el.closest("form") || el.closest('[role="form"]'); + if (form) { + return !!form.querySelector( + 'input[type="password"]:not([disabled])', + ); + } + // 2. No ? Walk up to 5 ancestor elements looking for a password input + // in any subtree (covers React/Vue apps that render outside ). + let node = el.parentElement; + for (let i = 0; i < 5 && node; i++, node = node.parentElement) { + if (node.querySelector('input[type="password"]:not([disabled])')) { + return true; + } + } + // 3. Last resort: any visible password field on the entire page. + // Only accept this if there is exactly one password field — avoids + // false-positives on complex pages (account settings, checkout, etc.). + const pwFields = visiblePasswordFields(); + return pwFields.length === 1; } function findUsernameField(pwField) { @@ -827,6 +865,11 @@ * Pass nothing/undefined to let this function read storage itself. */ async function decorateFields(knownItems) { + // Fast-path: if there are no password fields anywhere on the page, there is + // nothing to decorate. This prevents false-positive hooks on pages with + // standalone text inputs (search bars, filter fields, etc.). + if (!visiblePasswordFields().length) return; + if (knownItems != null) { // Caller supplied pre-filtered items — trust them, skip the storage read. _matchingItems = knownItems; @@ -1193,30 +1236,44 @@ } }); + // Debounce + narrow the MutationObserver: only re-scan when something that + // looks like a form input or a whole subtree with inputs was added/removed. + // This prevents constant re-scanning on SPA re-renders (tooltip shows, + // React state updates, etc.) that don't touch login form elements. + var _mutationTimer = null; _formObserver = new MutationObserver(function (mutations) { - // Ignore mutations caused by the extension's own injected elements - // (dropdown, icon buttons, save banner) to prevent re-decoration loops - // on SPAs that react to every DOM change. - var ownMutation = mutations.every(function (m) { - return Array.from(m.addedNodes) - .concat(Array.from(m.removedNodes)) - .every(function (node) { - if (!node || node.nodeType !== 1) return true; - var cls = node.className || ""; - var id = node.id || ""; - return ( - cls.indexOf("__pk") !== -1 || - id.indexOf("__pk") !== -1 || - (node.querySelector && - (node.querySelector("." + PK_BTN_CLASS) || - node.querySelector("#" + PK_DROPDOWN_ID))) - ); - }); + // Ignore mutations caused by the extension's own injected elements. + var hasRelevantChange = mutations.some(function (m) { + // addedNodes contains an , , or a subtree with either. + return Array.from(m.addedNodes).some(function (node) { + if (!node || node.nodeType !== 1) return false; + // Skip our own injected nodes. + var cls = node.className || ""; + var id = node.id || ""; + if (cls.indexOf("__pk") !== -1 || id.indexOf("__pk") !== -1) + return false; + // A new or element, or a container with one inside. + var tag = node.tagName; + if (tag === "INPUT" || tag === "FORM") return true; + if ( + node.querySelector && + (node.querySelector('input[type="password"]') || + node.querySelector("input") || + node.querySelector("form")) + ) + return true; + return false; + }); }); - if (ownMutation) return; - _hasNotifiedForm = false; - notifyFormDetected(); - decorateFields(); + if (!hasRelevantChange) return; + + // Debounce: wait 300 ms after the last relevant mutation before scanning. + clearTimeout(_mutationTimer); + _mutationTimer = setTimeout(function () { + _hasNotifiedForm = false; + notifyFormDetected(); + decorateFields(); + }, 300); }); _formObserver.observe(document.body, { childList: true, subtree: true }); }