04/26 Enhanced app and extension security

This commit is contained in:
2026-04-26 09:25:13 -04:00
parent ac91ea1fcc
commit cdda47872a
10 changed files with 378 additions and 37 deletions
+81 -12
View File
@@ -50,21 +50,71 @@
.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).
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|off)$/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(' ');
return CRED_HINTS.test(attrs);
}
function findUsernameField(pwField) {
// Helper: accept email/tel inputs unconditionally; text inputs only when
// they look like a genuine credential field.
function isCredentialType(el) {
if (el.type === 'email' || el.type === 'tel') return true;
if (el.type === 'text') return _isLikelyUsernameField(el);
return false;
}
// 1. Walk backwards through all inputs in DOM order.
const all = Array.from(document.querySelectorAll('input'));
const idx = all.indexOf(pwField);
for (let i = idx - 1; i >= 0; i--) {
const el = all[i];
if (!isVisible(el) || el.disabled) continue;
if (['email', 'text', 'tel'].includes(el.type)) return el;
if (isCredentialType(el)) return el;
}
// 2. Fallback: search within the same form/ancestor container.
// 2. Fallback: search within the same form / ancestor container.
// Prefer email inputs first, then scored text/tel inputs.
const container = pwField.closest('form') || pwField.closest('[role="form"]') || pwField.parentElement;
if (container) {
const candidate = container.querySelector('input[type="email"]:not([disabled]), input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])');
if (candidate && isVisible(candidate)) return candidate;
const emailCandidate = container.querySelector('input[type="email"]:not([disabled])');
if (emailCandidate && isVisible(emailCandidate)) return emailCandidate;
const textTelInputs = Array.from(
container.querySelectorAll('input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])')
);
const scored = textTelInputs.filter(el => isVisible(el) && _isLikelyUsernameField(el));
if (scored.length) return scored[0];
}
return null;
}
@@ -127,7 +177,7 @@
var freshItems = _matchingItems;
if (!freshItems.length) {
try {
var result = await chrome.storage.local.get('vault_items_cs');
var result = await chrome.storage.session.get('vault_items_cs');
var all = (result && result.vault_items_cs) || [];
freshItems = _filterForHost(all);
if (freshItems.length) _matchingItems = freshItems;
@@ -599,11 +649,11 @@
}
async function decorateFields() {
// Read from chrome.storage.local — reliable across all Chrome versions and
// does not depend on message delivery from the service worker.
// Read from chrome.storage.session — memory-only, cleared on browser close.
// Decrypted vault data must never be written to persistent (local) storage.
var all = [];
try {
var result = await chrome.storage.local.get('vault_items_cs');
var result = await chrome.storage.session.get('vault_items_cs');
all = (result && result.vault_items_cs) || [];
} catch (e) { }
@@ -621,14 +671,33 @@
// ── Vault item helpers ────────────────────────────────────────────────────────
/**
* Normalise a stored URL string so it is always parseable by `new URL()`.
* Handles bare domains ("github.com"), protocol-relative ("//github.com"),
* and fully-formed URLs ("https://github.com") identically.
*/
function _normaliseUrl(raw) {
if (!raw) return null;
var s = raw.trim();
if (/^https?:\/\//i.test(s)) return s; // already has a scheme
if (s.startsWith('//')) return 'https:' + s; // protocol-relative
return 'https://' + s; // bare domain or path
}
function _filterForHost(items) {
var host = location.hostname.replace(/^www\./, '');
return (items || []).filter(function (item) {
if (item.item_type !== 'password' || !(item.plain && item.plain.url)) return false;
try {
var h = new URL(item.plain.url).hostname.replace(/^www\./, '');
var normalised = _normaliseUrl(item.plain.url);
if (!normalised) return false;
var h = new URL(normalised).hostname.replace(/^www\./, '');
// Match exact domain or any subdomain relationship.
return h === host || h.endsWith('.' + host) || host.endsWith('.' + h);
} catch (e) { return false; }
} catch (e) {
console.warn('[PassKeeper] _filterForHost: could not parse URL:', item.plain.url, e.message);
return false;
}
});
}
@@ -646,7 +715,7 @@
async function classifyCredentials(username, password) {
var all = [];
try {
var result = await chrome.storage.local.get('vault_items_cs');
var result = await chrome.storage.session.get('vault_items_cs');
all = (result && result.vault_items_cs) || [];
} catch (e) { return 'new'; }
if (!all.length) return 'new';
@@ -816,7 +885,7 @@
// React instantly when the popup writes fresh vault data to local storage.
// This fires in the same tick as the write — no message delivery required.
chrome.storage.onChanged.addListener(function (changes, area) {
if (area === 'local' && changes.vault_items_cs) {
if (area === 'session' && changes.vault_items_cs) {
var allItems = (changes.vault_items_cs.newValue) || [];
_matchingItems = _filterForHost(allItems);
console.log('[PassKeeper] storage.onChanged: matched=' + _matchingItems.length + ' of ' + allItems.length + ' items');