Aug 26 - Update password detect against off field
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled

This commit is contained in:
2026-08-26 14:47:16 -04:00
parent b84a6d9245
commit 2f1afb143c
4 changed files with 287 additions and 40 deletions
+132 -38
View File
@@ -99,8 +99,21 @@
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
// <input type="text" id="login_username" placeholder="Username"
// autocomplete="off">
// 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|off)$/i;
/^(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.
@@ -1134,51 +1147,132 @@
});
}
// ── Form submission watch ─────────────────────────────────────────────────────
// ── Credential capture / save prompt ─────────────────────────────
// Guards against two triggers firing for the same login (e.g. a click handler
// that also submits a form). Cleared after a short window.
var _captureCooldown = false;
/**
* Collect the credentials currently entered within `scope` and offer to save
* them. `scope` is the <form> for a real submit, or the document for the
* fallback triggers below.
*/
async function maybeCaptureCredentials(scope) {
if (_captureCooldown) return;
var root = scope || document;
var pwField = Array.prototype.find.call(
root.querySelectorAll('input[type="password"]:not([disabled])'),
function (el) {
return isVisible(el) && el.value;
},
);
if (!pwField) return;
var userField =
findUsernameField(pwField) ||
root.querySelector('input[type="email"]:not([disabled])') ||
root.querySelector('input[type="text"]:not([disabled])');
var username =
(userField && userField.value && userField.value.trim()) || "";
var password = pwField.value;
if (!username || !password) return;
_captureCooldown = true;
setTimeout(function () {
_captureCooldown = false;
}, 2000);
removeDropdown();
// Check blocklist before doing anything else.
if (await isBlocked(location.hostname)) {
console.log(
"[PassKeeper] Site is blocklisted, skipping save banner:",
location.hostname,
);
return;
}
var credentialState = await classifyCredentials(username, password);
console.log(
"[PassKeeper] Credential state for",
location.hostname,
"→",
credentialState,
);
if (credentialState === "same") return;
setTimeout(function () {
showSaveBanner(username, password, credentialState);
}, 500);
}
/**
* Does this element look like the control that submits a login?
*
* Needed because many login UIs never use a <form> at all — the ASUS router
* admin page, for example, submits with
* <div class="button" onclick="preLogin();">Sign In</div>
* so no "submit" event is ever dispatched and the save prompt never appeared.
*/
function _looksLikeSubmitControl(el) {
if (!el || !el.tagName) return false;
var tag = el.tagName.toUpperCase();
if (tag === "BUTTON") return true;
if (tag === "INPUT" && /^(submit|button|image)$/i.test(el.type)) return true;
if (el.getAttribute("role") === "button") return true;
// Non-semantic controls: an inline click handler, or a button-ish class.
if (el.hasAttribute("onclick")) return true;
var cls = (el.getAttribute("class") || "").toLowerCase();
return /(^|[\s_-])(btn|button|submit|login|signin|sign-in)([\s_-]|$)/.test(cls);
}
function watchSubmissions() {
// 1. Real form submits.
document.addEventListener(
"submit",
async function (e) {
var form = e.target;
var pwField = form.querySelector(
'input[type="password"]:not([disabled])',
);
if (!pwField || !pwField.value) return;
function (e) {
maybeCaptureCredentials(e.target);
},
true,
);
var userField =
findUsernameField(pwField) ||
form.querySelector('input[type="email"]:not([disabled])') ||
form.querySelector('input[type="text"]:not([disabled])');
var username =
(userField && userField.value && userField.value.trim()) || "";
var password = pwField.value;
if (!username || !password) return;
removeDropdown();
// Check blocklist before doing anything else.
if (await isBlocked(location.hostname)) {
console.log(
"[PassKeeper] Site is blocklisted, skipping save banner:",
location.hostname,
);
return;
// 2. Clicks on anything that looks like a submit control. Required for the
// common case of a login UI built without a <form>, where no submit
// event fires and the save prompt would otherwise never appear.
document.addEventListener(
"click",
function (e) {
var node = e.target;
for (var i = 0; i < 5 && node && node !== document; i++) {
if (_looksLikeSubmitControl(node)) {
// Let the page's own handler run first.
setTimeout(function () {
maybeCaptureCredentials(document);
}, 0);
return;
}
node = node.parentElement;
}
},
true,
);
var credentialState = await classifyCredentials(username, password);
console.log(
"[PassKeeper] Credential state for",
location.hostname,
"\u2192",
credentialState,
);
if (credentialState === "same") return;
// 3. Enter pressed inside a credential field — the other way such forms
// get submitted without a <form>.
document.addEventListener(
"keydown",
function (e) {
if (e.key !== "Enter") return;
var el = e.target;
if (!el || el.tagName !== "INPUT") return;
if (el.type !== "password" && !_isLikelyUsernameField(el)) return;
setTimeout(function () {
showSaveBanner(username, password, credentialState);
}, 500);
maybeCaptureCredentials(document);
}, 0);
},
true,
);