feat: 按域名授权、iframe 多帧填充、自动提交限流修正,并补齐工程规范与文档

安全与权限:
- 站点访问权限改为 optional_host_permissions,保存配置时逐域名授权;移除 host_permissions 与 tabs,安装提示不再出现全站数据访问与浏览记录
- 主口令不再写入 storage.session,改用 IndexedDB 中不可导出的 CryptoKey 句柄恢复解锁,锁定即丢弃
- content script 注入范围只注册已授权域名,并随权限变化即时收敛

自动登录:
- 支持 iframe 内的登录表单:脚本注入所有帧,逐帧按自身地址匹配配置
- 手动填充改为逐帧探测,定向发送到真正含密码框的帧
- 限流修正:只在真正触发提交后计数;判定登录成功后立即清零;达到上限时页面给出可见提示;重置判断只看启用中的配置
- SPA 重试改为 DOM 变更门控 + 退避,并单独监听已发现的 Shadow Root

工程化与文档:
- 引入 ESLint(扁平配置)与 Prettier,CI 增加 lint 与 format:check
- 信息类日志改为 debugLog(默认静默,chrome.storage.local.debugLog 开关)
- 测试 123 → 128 用例(新增权限、iframe、限流相关用例)
- README / PRIVACY / CHANGELOG 同步
This commit is contained in:
陈银军
2026-09-18 00:05:20 +08:00
parent 8883a5c506
commit 1b0c3c012c
48 changed files with 3364 additions and 680 deletions
+275 -30
View File
@@ -20,23 +20,31 @@ const tick = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// autoSubmit 的最小延迟是 300ms(content.js 里 Math.max(300, delayMs))
const AFTER_SUBMIT = 400;
function createChromeMock({ configs = [], attempts = {} } = {}) {
function createChromeMock({ configs = [], attempts = {}, activeEnvId = null } = {}) {
const state = {
configs,
activeEnvId,
attempts: new Map(Object.entries(attempts)),
calls: [],
messageListener: null,
// 每个帧注入 content.js 都会注册一个监听器;allFrames 场景下用最后一个(最内层帧)
messageListeners: [],
};
const api = {
runtime: {
id: "mock-extension-id",
onMessage: { addListener: (fn) => { state.messageListener = fn; } },
onMessage: {
addListener: (fn) => {
state.messageListener = fn;
state.messageListeners.push(fn);
},
},
sendMessage: (message) => {
state.calls.push(message.action);
switch (message.action) {
case "loadAllConfigs":
return Promise.resolve({ configs: state.configs });
return Promise.resolve({ configs: state.configs, activeEnvId: state.activeEnvId });
case "getVaultStatus":
return Promise.resolve({ hasVault: false, unlocked: true, pendingImport: false });
case "getDomainAttempts": {
@@ -68,28 +76,64 @@ function createChromeMock({ configs = [], attempts = {} } = {}) {
return { api, state };
}
/** 在 jsdom 页面里执行 utils.js + content.js(与浏览器一致:按普通脚本顺序执行) */
function loadContentPage({ html = "", url = "https://example.com/login", configs = [], attempts = {} } = {}) {
/**
* jsdom 没有布局引擎(offsetParent 恒为 null、offsetWidth 恒为 0),
* 而 content.js 会用它们判断元素是否可见。这里按 realm 逐个打补丁:
* 主窗口与每个 iframe 都各有自己的 HTMLElement.prototype,必须分别处理。
*/
function patchLayout(win) {
Object.defineProperty(win.HTMLElement.prototype, "offsetWidth", {
get() {
return 100;
},
configurable: true,
});
Object.defineProperty(win.HTMLElement.prototype, "offsetHeight", {
get() {
return 20;
},
configurable: true,
});
Object.defineProperty(win.HTMLElement.prototype, "offsetParent", {
get() {
return this.parentElement || this.ownerDocument.body;
},
configurable: true,
});
// jsdom 未实现 CSS.escape(真实浏览器有)
if (!win.CSS) win.CSS = {};
if (!win.CSS.escape)
win.CSS.escape = (s) => String(s).replace(/[^a-zA-Z0-9_\u00a0-\uffff-]/g, (c) => "\\" + c);
}
/**
* 在 jsdom 页面里执行 utils.js + content.js(与浏览器一致:按普通脚本顺序执行)。
* setup 回调在注入脚本之前执行,用于准备"脚本加载前就必须存在"的结构(例如 Shadow Root)。
*/
function loadContentPage({
html = "",
url = "https://example.com/login",
configs = [],
attempts = {},
activeEnvId = null,
timing,
setup,
} = {}) {
const dom = new JSDOM(`<!DOCTYPE html><html><body>${html}</body></html>`, {
url,
runScripts: "dangerously",
});
const win = dom.window;
// 让所有元素默认"可见"(jsdom 无布局引擎)
Object.defineProperty(win.HTMLElement.prototype, "offsetWidth", { get() { return 100; }, configurable: true });
Object.defineProperty(win.HTMLElement.prototype, "offsetHeight", { get() { return 20; }, configurable: true });
Object.defineProperty(win.HTMLElement.prototype, "offsetParent", {
get() { return this.parentElement || this.ownerDocument.body; },
configurable: true,
});
// jsdom 未实现 CSS.escape(真实浏览器有)
if (!win.CSS) win.CSS = {};
if (!win.CSS.escape) win.CSS.escape = (s) => String(s).replace(/[^a-zA-Z0-9_\u00a0-\uffff-]/g, (c) => "\\" + c);
patchLayout(win);
// 缩短时间常量(30 秒超时 / 4 秒结果观察),否则这些用例要真等半分钟
if (timing) win.__autoLoginTiming = timing;
const chrome = createChromeMock({ configs, attempts });
const chrome = createChromeMock({ configs, attempts, activeEnvId });
win.chrome = chrome.api;
if (setup) setup(win, win.document);
const inject = (code) => {
const el = win.document.createElement("script");
el.textContent = code;
@@ -139,7 +183,10 @@ describe("content - 字段探测", () => {
assert.equal(ctx.win.findPasswordField({}).getAttribute("name"), "pass");
assert.equal(ctx.win.findUsernameField({}).getAttribute("name"), "username");
assert.equal(ctx.win.findSubmitButton({}, ctx.doc.querySelector("input[type=password]")).textContent, "登录");
assert.equal(
ctx.win.findSubmitButton({}, ctx.doc.querySelector("input[type=password]")).textContent,
"登录",
);
});
it("显式选择器优先于自动探测", () => {
@@ -279,7 +326,12 @@ describe("content - 填充与提交", () => {
});
let submitted = 0;
const form = ctx.doc.querySelector("form");
Object.defineProperty(form, "requestSubmit", { value: () => { submitted++; }, configurable: true });
Object.defineProperty(form, "requestSubmit", {
value: () => {
submitted++;
},
configurable: true,
});
ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true);
await tick(AFTER_SUBMIT);
@@ -295,7 +347,11 @@ describe("content - 自动填充主流程", () => {
</form>`;
it("域名命中配置时自动填充", async () => {
const ctx = loadContentPage({ html: LOGIN_HTML, url: "https://example.com/login", configs: [makeConfig()] });
const ctx = loadContentPage({
html: LOGIN_HTML,
url: "https://example.com/login",
configs: [makeConfig()],
});
await tick(60);
assert.equal(ctx.doc.getElementById("u").value, "auto-user");
@@ -303,7 +359,11 @@ describe("content - 自动填充主流程", () => {
});
it("域名不匹配时什么都不做", async () => {
const ctx = loadContentPage({ html: LOGIN_HTML, url: "https://other.com/login", configs: [makeConfig()] });
const ctx = loadContentPage({
html: LOGIN_HTML,
url: "https://other.com/login",
configs: [makeConfig()],
});
await tick(60);
assert.equal(ctx.doc.getElementById("u").value, "");
@@ -327,7 +387,7 @@ describe("content - 自动填充主流程", () => {
assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"));
});
it("失败次数达到上限后只填充、不自动提交", async () => {
it("达到上限后只填充、不自动提交,并给出可见提示", async () => {
const ctx = loadContentPage({
html: LOGIN_HTML,
configs: [makeConfig({ autoSubmit: true })],
@@ -340,6 +400,80 @@ describe("content - 自动填充主流程", () => {
assert.equal(ctx.doc.getElementById("u").value, "auto-user", "仍然填充");
assert.equal(clicks, 0, "不应自动提交");
assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts"));
// 用户必须能看懂"为什么只填不提交",否则会以为扩展坏了
assert.ok(ctx.doc.body.innerHTML.includes("attemptToastTitle"), "应显示暂停提示浮窗");
});
it("命中配置但页面上没有登录框时不消耗计数(浏览站内页面不会被误扣)", async () => {
const ctx = loadContentPage({
html: `<div>登录成功后的站内页面</div>`,
configs: [makeConfig({ autoSubmit: true })],
timing: { fillTimeoutMs: 120 },
});
await tick(500);
assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts"), "没有提交就不应计数");
assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "始终没有登录框 → 清掉旧计数");
});
it("只有真正触发提交之后才累加计数", async () => {
const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })] });
ctx.doc.getElementById("btn").addEventListener("click", () => {});
await tick(150);
assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交前不应计数");
await tick(300); // 提交延迟 300ms
assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交后才计数");
});
it("提交后登录框消失视为登录成功并重置计数(SPA 场景)", async () => {
const ctx = loadContentPage({
html: LOGIN_HTML,
configs: [makeConfig({ autoSubmit: true })],
timing: { submitResultCheckMs: 120 },
});
ctx.doc.getElementById("btn").addEventListener("click", () => {});
await tick(330); // 300ms 时已提交
assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交后先计数");
// 模拟 SPA 登录成功:登录表单从页面上消失
ctx.doc.querySelector("form").remove();
await tick(160);
assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "登录成功应重置计数");
});
it("提交后登录框仍在(登录失败)不重置计数", async () => {
const ctx = loadContentPage({
html: LOGIN_HTML,
configs: [makeConfig({ autoSubmit: true })],
timing: { submitResultCheckMs: 120 },
});
ctx.doc.getElementById("btn").addEventListener("click", () => {});
await tick(560); // 覆盖 300ms 提交 + 120ms 结果观察
assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"));
assert.ok(!ctx.chrome.state.calls.includes("resetDomainAttempts"), "登录框仍在,视为失败,不应重置");
});
it("被禁用的配置不再阻止计数重置", async () => {
const ctx = loadContentPage({
url: "https://example.com/login/page",
html: `<div id="app"></div>`,
configs: [
makeConfig({ id: "a", domain: "example.com/login/*" }),
makeConfig({ id: "b", domain: "example.com", enabled: false }),
],
});
// 从 /login/page 跳到 /dashboard:只有被禁用的那条还匹配旧地址
ctx.dom.reconfigure({ url: "https://example.com/dashboard" });
ctx.doc.getElementById("app").textContent = "route changed"; // 触发 MutationObserver
await tick(80);
assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "启用中的配置离开匹配范围应重置");
});
it("失败计数超过冷却时间后重新计数并允许提交", async () => {
@@ -416,9 +550,115 @@ describe("content - SPA 延迟渲染", () => {
<input type="password" id="p">
</form>`;
await tick(700); // 重试间隔 500ms
await tick(700);
assert.equal(ctx.doc.getElementById("u").value, "auto-user");
});
it("Shadow DOM 内部后渲染的登录框也能填充(light DOM 完全没变)", async () => {
let shadow;
loadContentPage({
html: `<div id="host"></div>`,
configs: [makeConfig()],
setup: (win, doc) => {
shadow = doc.getElementById("host").attachShadow({ mode: "open" });
},
});
// 首轮扫描:发现空的 shadow root 并开始监听它
await tick(80);
assert.equal(shadow.querySelectorAll("input").length, 0);
// 只改 shadow 内部,body 上的 MutationObserver 感知不到这种变化
shadow.innerHTML = `<form>
<input type="text" name="username" id="u">
<input type="password" id="p">
</form>`;
await tick(700);
assert.equal(shadow.getElementById("u").value, "auto-user");
});
});
describe("content - iframe 内的登录表单(allFrames)", () => {
const FORM_HTML = `<form>
<input type="text" name="username" id="u">
<input type="password" id="p">
</form>`;
/** 在页面里创建同源 iframe,并按 allFrames 的方式往帧内注入脚本 */
function injectIntoFrame(ctx, html) {
const iframe = ctx.doc.createElement("iframe");
ctx.doc.body.appendChild(iframe);
const iwin = iframe.contentWindow;
const idoc = iframe.contentDocument;
idoc.body.innerHTML = html;
patchLayout(iwin); // iframe 是独立的 realm,需要单独打布局补丁
iwin.chrome = ctx.chrome.api;
for (const code of [UTILS_CODE, CONTENT_CODE]) {
const el = idoc.createElement("script");
el.textContent = code;
idoc.head.appendChild(el);
}
return { iwin, idoc };
}
it("iframe 内的表单能被填充(按 iframe 自己的地址匹配)", async () => {
// iframe 在 jsdom 里是 about:blank,用通配规则让它命中;顶层帧没有表单所以不会误填
const ctx = loadContentPage({ html: `<div id="top"></div>`, configs: [makeConfig({ domain: "*" })] });
const { idoc } = injectIntoFrame(ctx, FORM_HTML);
await tick(150);
assert.equal(idoc.getElementById("u").value, "auto-user");
assert.equal(idoc.getElementById("p").value, "auto-pass");
});
it("子帧命中多个环境时不弹浮窗,按当前激活环境填充(顶层帧照旧弹窗)", async () => {
const ctx = loadContentPage({
html: `<div id="top"></div>`,
configs: [
makeConfig({ id: "c1", envId: "e1", envName: "个人", domain: "*", username: "personal@x.com" }),
makeConfig({ id: "c2", envId: "e2", envName: "生产", domain: "*", username: "prod@x.com" }),
],
activeEnvId: "e2",
});
const { idoc } = injectIntoFrame(ctx, FORM_HTML);
await tick(150);
assert.equal(idoc.getElementById("u").value, "prod@x.com", "子帧应按激活环境选用凭据");
assert.equal(idoc.getElementById("auto-login-env-picker"), null, "子帧不弹浮窗");
assert.ok(ctx.doc.getElementById("auto-login-env-picker"), "顶层帧仍然弹浮窗");
});
it("子帧收到 manualFillMulti 时同样按激活环境填充", async () => {
const ctx = loadContentPage({
html: `<div id="top"></div>`,
configs: [
makeConfig({ id: "c1", envId: "e1", domain: "*", username: "personal@x.com" }),
makeConfig({ id: "c2", envId: "e2", domain: "*", username: "prod@x.com" }),
],
activeEnvId: "e2",
});
const { idoc } = injectIntoFrame(ctx, FORM_HTML);
// 最后注册的监听器来自最内层帧
const listeners = ctx.chrome.state.messageListeners;
const frameListener = listeners[listeners.length - 1];
frameListener({ action: "manualFillMulti", configs: [...ctx.chrome.state.configs] }, {}, () => {});
await tick(150);
assert.equal(idoc.getElementById("u").value, "prod@x.com");
assert.equal(idoc.getElementById("auto-login-env-picker"), null);
});
it("pickConfigForFrame:优先当前激活环境,其次第一条", () => {
const ctx = loadContentPage({ html: "" });
const a = makeConfig({ id: "a", envId: "e1" });
const b = makeConfig({ id: "b", envId: "e2" });
assert.equal(ctx.win.pickConfigForFrame([a, b], "e2").id, "b");
assert.equal(ctx.win.pickConfigForFrame([a, b], "e9").id, "a");
assert.equal(ctx.win.pickConfigForFrame([a, b], null).id, "a");
});
});
describe("content - 手动填充消息", () => {
@@ -434,7 +674,9 @@ describe("content - 手动填充消息", () => {
ctx.chrome.state.messageListener(
{ action: "manualFill", config: makeConfig({ username: "manual@x.com" }) },
{},
(r) => { response = r; }
(r) => {
response = r;
},
);
await tick(30);
@@ -447,11 +689,9 @@ describe("content - 手动填充消息", () => {
const ctx = loadContentPage({ html: `<div>没有表单</div>` });
let response = null;
ctx.chrome.state.messageListener(
{ action: "manualFill", config: makeConfig() },
{},
(r) => { response = r; }
);
ctx.chrome.state.messageListener({ action: "manualFill", config: makeConfig() }, {}, (r) => {
response = r;
});
await tick(30);
assert.equal(response.success, false);
@@ -464,9 +704,14 @@ describe("content - 手动填充消息", () => {
let response = null;
ctx.chrome.state.messageListener(
{ action: "manualFillMulti", configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })] },
{
action: "manualFillMulti",
configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })],
},
{},
(r) => { response = r; }
(r) => {
response = r;
},
);
await tick(30);