/** * extension/content/content.js — PassKeeper content script. * * 1. Detects login forms → notifies background (badge count). * 2. Injects a PassKeeper icon button OUTSIDE the DOM (position:fixed, tracked * to the field via scroll/resize) into username AND password fields. * This avoids breaking site layouts (flex/grid parents, React-controlled inputs). * 3. Clicking the icon OR focusing a decorated field shows a suggestion dropdown. * 4. "More options…" shows a second panel with vault/generator actions. * 5. Listens for DO_AUTOFILL from the popup → fills fields. * 6. Watches form submissions → shows save-credentials banner. */ (() => { "use strict"; const PK_ATTR = "data-pk-decorated"; const PK_BTN_CLASS = "__pk_btn__"; 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; let _matchingItems = []; // Map from field element → its fixed-position icon button element const _fieldBtnMap = new WeakMap(); // ── Helpers ────────────────────────────────────────────────────────────────── function escHtml(str) { // " and ' are both required: templates in this file use a mix // of double- and single-quoted attributes, and an unescaped quote of // either kind lets injected text break out of an attribute. // This copy previously escaped neither, while injecting attacker-influenced // values (site hostname, stored item names) into attributes. return String(str ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * More robust visibility check than offsetParent (which fails for * position:fixed elements and some modern layouts). */ function isVisible(el) { if (!el || !el.getBoundingClientRect) return false; if (el.disabled) return false; const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return false; const style = window.getComputedStyle(el); if ( style.display === "none" || style.visibility === "hidden" || style.opacity === "0" ) return false; return true; } /** * Returns a debounced version of `fn` that waits `ms` milliseconds after * the last call before firing. Used to avoid re-rendering the dropdown on * every keystroke. */ function _debounce(fn, ms) { var timer; return function () { var args = arguments; var ctx = this; clearTimeout(timer); timer = setTimeout(function () { fn.apply(ctx, args); }, ms); }; } /** * True when this page is safe enough to put a stored credential into. * * The extension holds http://*\/* permission on purpose: a great many devices * that genuinely need a password manager — routers, NAS boxes, printers, * self-hosted admin panels — are only reachable over plain HTTP on the local * network, and dropping the permission would make PassKeeper useless exactly * where people reuse weak passwords most. * * What is NOT acceptable is filling a credential into a plaintext page on the * public internet, where anyone on the path can read it. Loopback and RFC1918 * / RFC4193 / link-local addresses and .local names are treated as acceptable; * every other http:// origin gets a warning in the dropdown before the user * chooses an item. */ function _isTrustworthyOrigin() { if (location.protocol === "https:" || location.protocol === "file:") return true; var h = (location.hostname || "").toLowerCase().replace(/^\[|\]$/g, ""); if (h === "localhost" || h.endsWith(".localhost")) return true; // Reserved TLDs that cannot be registered publicly. if (/\.(local|lan|home|internal)$/.test(h)) return true; // RFC4193 unique-local / RFC4291 link-local IPv6. if (h === "::1") return true; if (/^f[cd][0-9a-f]{2}:/i.test(h) || /^fe80:/i.test(h)) return true; // IPv4 must match in FULL. Prefix checks like h.startsWith("127.") also // accept attacker-registrable names such as "127.0.0.1.evil.com", which // would silently suppress the insecure-page warning on a hostile site. var m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (!m) return false; var o = m.slice(1).map(Number); if (o.some(function (n) { return n > 255; })) return false; if (o[0] === 127) return true; // loopback if (o[0] === 10) return true; // RFC1918 if (o[0] === 192 && o[1] === 168) return true; // RFC1918 if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; // RFC1918 if (o[0] === 169 && o[1] === 254) return true; // link-local return false; } /** * Prepend an unmissable warning to the dropdown on plaintext public pages. * Deliberately a warning and not a block: filling is always user-initiated, * and silently offering nothing would look like a broken extension. */ function _insecureWarningRow() { if (_isTrustworthyOrigin()) return null; var row = document.createElement("div"); Object.assign(row.style, { padding: "8px 12px", background: "#fdecea", color: "#b71c1c", borderBottom: "1px solid #f5c6cb", fontSize: "12px", lineHeight: "1.35", }); row.textContent = "⚠ This page is not encrypted (http://). A credential filled here " + "can be read by anyone on the network."; return row; } function visiblePasswordFields() { return Array.from( document.querySelectorAll('input[type="password"]'), ).filter((el) => isVisible(el) && !el.disabled); } /** * Returns true only if the input field carries signals suggesting it * collects a credential (username / email / phone) — not a generic * text field such as a search box, full-name field, or address field. * * Scoring precedence: * 1. autocomplete="username"|"email"|"tel" → definite YES * 2. Non-credential autocomplete value → definite NO * 3. name / id / placeholder / aria-label contain a credential keyword → YES * 4. Otherwise → NO (do not decorate) */ function _isLikelyUsernameField(el) { const CRED_HINTS = /user|email|mail|login|phone|tel|mobile|account/i; const ac = (el.getAttribute("autocomplete") || "").toLowerCase().trim(); // Strongest positive signal. if (["username", "email", "tel"].includes(ac)) return true; // Definite negative signals (Chrome's autocomplete token set). // // "off" is deliberately NOT in this list. It says nothing about whether a // field holds a credential — routers, banks and admin panels set it on // login inputs precisely to discourage password managers, and every major // password manager (and Chrome itself, for password fields) ignores it. // Treating it as a negative signal meant a field as obvious as // // was rejected before the keyword check below ever ran. // // Letting "off" fall through is safe: the field still has to carry a // credential keyword AND sit near a password input (_hasPasswordSibling) // before it is decorated. const NON_CRED_AC = /^(name|given-name|family-name|additional-name|honorific-prefix|honorific-suffix|organization|street-address|address-line[123]|address-level[1234]|country|country-name|postal-code|cc-|transaction-|language|bday|sex|url|photo|search|new-password|current-password|one-time-code)$/i; if (ac && NON_CRED_AC.test(ac)) return false; // Check name, id, placeholder, and aria-label for credential keywords. const attrs = [ el.getAttribute("name") || "", el.getAttribute("id") || "", el.getAttribute("placeholder") || "", el.getAttribute("aria-label") || "", ].join(" "); 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