05/09 Update: make hooks smarter

This commit is contained in:
Nguyen Ngo
2026-05-09 15:41:10 -04:00
parent 267c36af4d
commit 312c27f31a
+80 -23
View File
@@ -18,6 +18,10 @@
const PK_DROPDOWN_ID = "__pk_dropdown__"; const PK_DROPDOWN_ID = "__pk_dropdown__";
const VAULT_URL = "https://pwkeeper.ngodanguyen.tech/vault"; 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 _bannerEl = null;
let _hasNotifiedForm = false; let _hasNotifiedForm = false;
let _formObserver = null; let _formObserver = null;
@@ -107,7 +111,41 @@
el.getAttribute("aria-label") || "", el.getAttribute("aria-label") || "",
].join(" "); ].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 <form> ancestor.
const form =
el.closest("form") || el.closest('[role="form"]');
if (form) {
return !!form.querySelector(
'input[type="password"]:not([disabled])',
);
}
// 2. No <form>? Walk up to 5 ancestor elements looking for a password input
// in any subtree (covers React/Vue apps that render outside <form>).
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) { function findUsernameField(pwField) {
@@ -827,6 +865,11 @@
* Pass nothing/undefined to let this function read storage itself. * Pass nothing/undefined to let this function read storage itself.
*/ */
async function decorateFields(knownItems) { 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) { if (knownItems != null) {
// Caller supplied pre-filtered items — trust them, skip the storage read. // Caller supplied pre-filtered items — trust them, skip the storage read.
_matchingItems = knownItems; _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) { _formObserver = new MutationObserver(function (mutations) {
// Ignore mutations caused by the extension's own injected elements // Ignore mutations caused by the extension's own injected elements.
// (dropdown, icon buttons, save banner) to prevent re-decoration loops var hasRelevantChange = mutations.some(function (m) {
// on SPAs that react to every DOM change. // addedNodes contains an <input>, <form>, or a subtree with either.
var ownMutation = mutations.every(function (m) { return Array.from(m.addedNodes).some(function (node) {
return Array.from(m.addedNodes) if (!node || node.nodeType !== 1) return false;
.concat(Array.from(m.removedNodes)) // Skip our own injected nodes.
.every(function (node) { var cls = node.className || "";
if (!node || node.nodeType !== 1) return true; var id = node.id || "";
var cls = node.className || ""; if (cls.indexOf("__pk") !== -1 || id.indexOf("__pk") !== -1)
var id = node.id || ""; return false;
return ( // A new <input> or <form> element, or a container with one inside.
cls.indexOf("__pk") !== -1 || var tag = node.tagName;
id.indexOf("__pk") !== -1 || if (tag === "INPUT" || tag === "FORM") return true;
(node.querySelector && if (
(node.querySelector("." + PK_BTN_CLASS) || node.querySelector &&
node.querySelector("#" + PK_DROPDOWN_ID))) (node.querySelector('input[type="password"]') ||
); node.querySelector("input") ||
}); node.querySelector("form"))
)
return true;
return false;
});
}); });
if (ownMutation) return; if (!hasRelevantChange) return;
_hasNotifiedForm = false;
notifyFormDetected(); // Debounce: wait 300 ms after the last relevant mutation before scanning.
decorateFields(); clearTimeout(_mutationTimer);
_mutationTimer = setTimeout(function () {
_hasNotifiedForm = false;
notifyFormDetected();
decorateFields();
}, 300);
}); });
_formObserver.observe(document.body, { childList: true, subtree: true }); _formObserver.observe(document.body, { childList: true, subtree: true });
} }