Aug 26 - Update password detect against off field 2
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 15:05:48 -04:00
parent 2f1afb143c
commit 0d7d9c1403
12 changed files with 677 additions and 99 deletions
+130 -16
View File
@@ -32,10 +32,17 @@
// ── 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, "&lt;")
.replace(/>/g, "&gt;");
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
@@ -74,6 +81,69 @@
};
}
/**
* 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"]'),
@@ -231,24 +301,65 @@
el.style.outline = "";
}, 1500);
});
// Autologin: submit the form automatically after filling.
// Autologin: submit automatically after filling.
if (autologin) {
const form = pwField.closest("form");
if (form) {
setTimeout(function () {
// Prefer clicking a visible submit button so site-specific submit
// handlers (React, Vue, etc.) fire correctly.
var submitBtn = form.querySelector(
'[type="submit"]:not([disabled])',
);
if (submitBtn) {
submitBtn.click();
} else {
form.submit();
}
}, 400);
setTimeout(function () {
var form = pwField.closest("form");
// Prefer clicking a real control so site-specific handlers (React, Vue,
// inline onclick) fire — form.submit() bypasses them entirely.
var control = _findSubmitControl(pwField);
if (control) {
control.click();
} else if (form) {
form.submit();
}
}, 400);
}
}
// Controls that look like submits but would discard the login instead.
var _NEGATIVE_CONTROL = /cancel|reset|back|close|forgot|register|sign\s*up|create/i;
/**
* Find the control that submits the login containing `pwField`.
*
* Autologin previously required a <form> and did nothing without one, so it
* silently never worked on the many login UIs built from plain divs (the ASUS
* router admin page submits with
* <div class="button" onclick="preLogin();">Sign In</div>).
*
* Returns null when nothing convincing is found — better to leave the filled
* form for the user than to click the wrong thing.
*/
function _findSubmitControl(pwField) {
var scope = pwField.closest("form") || pwField.closest('[role="form"]');
if (scope) {
var explicit = scope.querySelector('[type="submit"]:not([disabled])');
if (explicit && isVisible(explicit)) return explicit;
}
// No form (or no explicit submit in it): search progressively wider
// ancestors so the nearest plausible control wins.
var node = scope || pwField.parentElement;
for (var depth = 0; depth < 5 && node; depth++, node = node.parentElement) {
var candidates = node.querySelectorAll(
'button, [role="button"], [onclick], input[type="submit"], ' +
'input[type="button"], div, a',
);
for (var i = 0; i < candidates.length; i++) {
var el = candidates[i];
if (el === pwField || el.disabled) continue;
if (!_looksLikeSubmitControl(el)) continue;
if (!isVisible(el)) continue;
// Only leaf-ish controls — a wrapping div can carry a button class.
if (el.querySelector("input, button")) continue;
var label = (el.textContent || el.value || "").trim();
if (label.length > 40) continue;
if (_NEGATIVE_CONTROL.test(label)) continue;
return el;
}
}
return null;
}
// ── Icon button (fixed-position, outside the DOM tree of the field) ───────────
@@ -318,6 +429,9 @@
overflow: "hidden",
});
var warning = _insecureWarningRow();
if (warning) dropdown.appendChild(warning);
if (panel === "more") {
buildMorePanel(dropdown, anchorField, pwField, freshItems, filterText);
} else {