fix(安全)★★: 主进程零导航拦截 —— 加 will-navigate / 新窗口拒绝 + 单实例锁
**主进程此前没有任何守卫**(实测 grep 0 命中:setWindowOpenHandler、
will-navigate、requestSingleInstanceLock 全无)。
在 `loadFile(dist/index.html)` 下,邮件正文里的链接(react-markdown 会渲染
`<a href>`)点下去会**在应用窗口里导航走** —— 一个 `href="https://…"` 就足以
把整个应用窗口变成浏览器,而窗口标题与 preload 注入的 API base/token
全部暴露在那个站点上。
★ **本条防的不是 XSS**:`markdown-xss` 守的是渲染层(无 rehype-raw、
defaultUrlTransform 中和 javascript:),**目前没有 XSS 面**。
这里防的是**导航逃逸** —— 让外部站点**借用**这个窗口与 preload 上下文。
两者失效方式不同,所以分开钉。
**① setWindowOpenHandler** ⇒ 一律 `deny`,地址交系统浏览器。
`allow` 会给那个站点一个**带 preload 的窗口**。
**② will-navigate** ⇒ 拦下并 preventDefault。
★ 但必须**放行本应用自己的加载**(prod 的 `file://` / dev 的 `DEV_URL`)
—— 只会 preventDefault 的实现会把应用自己锁死,首屏进不去。
这是「过严的守卫同样是缺陷」,判据专门为它加了一格。
**③ 外跳只放行 http/https**:file: / javascript: / 自定义协议交给
`shell.openExternal` 意图不可控;`new URL()` 对畸形输入会抛,必须 catch。
**④ requestSingleInstanceLock**:双击图标此前会起**两个进程** ——
两条 SSE 连接、两套 `accounts.json` 并发写入(那个文件是 tmp+rename 原子写,
并发即「后写的赢」),而用户以为只有一扇窗。
**同时修的两处双提交**(形状与 ④ 同源,都是「busy/state 要到提交后才为真」):
· PermissionPanel.submit:审批是本工程**唯一带副作用且不可撤销**的动作
⇒ 同帧两次激活会发出**两条** decidePermission(服务端记两次账)。
照同文件 ForwardBar 的 `inFlight` 形状改。
· Attachments.handleFiles:`uploading` 只加在按钮的 disabled 上,
`<input type=file>` 本身无闸门 ⇒ 上传期间重入会拿到**上一次的 items 闭包**
⇒ onChange 把上一次结果整批覆盖,表现为「附件少了」且**无任何提示**。
★ 清 `input.value` 必须与闸门**成对**提前:只提前清而不加闸门,
会亲手制造「上传中重选同一文件 ⇒ value 已空 ⇒ change 照触发 ⇒ 二次上传」。
**判据(新建 main-process-security.test.mjs,14 格,已接线)**
按括号配对取函数体/实参,**不用** `\{[\s\S]{0,80}` 窗口(§1 第三次露头)。
变异测试 **10 个全部抓住**:去掉 deny / 去掉 preventDefault / 守卫写死不放行自己 /
放开所有 scheme / 拿不到锁不退出 / sandbox:false / contextIsolation:false /
second-instance 删 show() / 删整个 if / 删 restore()。
★ PermissionPanel 那条新判据第一版是**假绿**:`fireEvent.click` 连发两次
(不在 act 里)**删掉闸门也照样绿** —— 两次 fireEvent 之间 React 提交了一次,
第二次点到的是已 disabled 的按钮。必须放进**同一个 act**(同批次、不提交)
才复现。`userEvent.click` 每次都 await 一轮 ⇒ 它测不到同帧。
这条已写进判据注释,免得下一个人再写一次。
**边界 / 未做**
· 这些是**静态**断言,证明守卫被写下来了,**不证明运行时生效**(那要真起窗口点链接)。
· 没有把 webPreferences 的值当"够不够安全"来评审 —— 那属于安全评审,不属可机检;
本条只钉「不许被放松」。
· X-2(index.html 无 CSP)没做:首帧防闪屏那段内联脚本要求 'unsafe-inline',
加 CSP 是在**降低**强度的前提下加一层,值得单独一轮 + 真机验闪屏,不夹在本次。
This commit is contained in:
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
@ -154,6 +154,46 @@ describe('PermissionPanel 决策', () => {
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
/*
|
||||
* ★ 上一格测的是**渲染之后**的 disabled —— 它**抓不到本条要拦的形状**。
|
||||
* 那个形状发生在 React 提交**之前**:`busy` 还没变成 true,
|
||||
* 两次激活(双击 / Enter 连按 / 鼠标+键盘同时)都读到 `busy === false`。
|
||||
* 而审批是本工程里**唯一带副作用且不可撤销**的动作
|
||||
* ⇒ 服务端记两次账、Agent 侧收两条通知,且没有撤销入口。
|
||||
*
|
||||
* ★★ 这里踩过一次**假绿**,值得把形状记准(2026-10-03 实测):
|
||||
* 我第一版写 `fireEvent.click(b); fireEvent.click(b);`(不在 `act` 里),
|
||||
* 断言 `toHaveBeenCalledTimes(1)` —— **删掉闸门它也照样绿**。
|
||||
* 真因:两次 `fireEvent` 之间 React 会提交一次(自动批处理),
|
||||
* 于是第二次点到的按钮**已经 disabled** ⇒ 只发一条,与闸门无关。
|
||||
* ⇒ 必须把两次点击放进**同一个 `act`**(同一批次、中间不提交)才复现。
|
||||
* 实测对照(闸门已删):`A calls = 2`(同 act 内两次 click)。
|
||||
* `userEvent.click` 每次都 await 一轮 ⇒ **它测不到同帧**,别用。
|
||||
*/
|
||||
it('★ 同一帧内的两次激活只发出一条 decidePermission(busy 要到提交后才为真)', async () => {
|
||||
let release: (v: any) => void = () => {};
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockReturnValue(
|
||||
new Promise(res => {
|
||||
release = res;
|
||||
}) as any
|
||||
);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
const approve = screen.getByRole('button', { name: /同意/ });
|
||||
// ★ 同**一个** act:两次激活落在同一批次,中间没有 React 提交
|
||||
await act(async () => {
|
||||
fireEvent.click(approve);
|
||||
fireEvent.click(approve);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
release({ status: 'decided' });
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('提交失败时恢复可点,不假装已决策', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockRejectedValue(new Error('500'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
242
client/electron/test/main-process-security.test.mjs
Normal file
242
client/electron/test/main-process-security.test.mjs
Normal file
@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Electron **主进程**的安全防线。
|
||||
*
|
||||
* ## 为什么需要这条
|
||||
*
|
||||
* `markdown-xss.test.mjs` 守的是**渲染层**(raw HTML 转义、`javascript:` 中和)。
|
||||
* 这一条守的是**主进程**那一侧 —— 它此前没有任何判据。
|
||||
*
|
||||
* 事实前提(2026-10-03 实测):`main.cjs` 此前**没有** `setWindowOpenHandler`、
|
||||
* **没有** `will-navigate`、**没有** `requestSingleInstanceLock`(grep 0 命中)。
|
||||
* 在 `loadFile(dist/index.html)` 下,邮件正文里的链接(`react-markdown` 会渲染
|
||||
* `<a href>`)点下去会**在应用窗口里导航走**:
|
||||
* 一个 `href="https://…"` 就足以把整个应用窗口变成浏览器,
|
||||
* 而窗口标题与 preload 注入的 API base/token 全部暴露在那个站点上。
|
||||
*
|
||||
* ★ **本条不是"防 XSS"** —— 渲染层没有 XSS 面(`markdown-xss` 守着)。
|
||||
* 它防的是**导航逃逸**:让外部站点**借用**这个窗口与 preload 上下文。
|
||||
* 两件事的失效方式不同,所以要分别钉。
|
||||
*
|
||||
* ## 怎么读 main.cjs
|
||||
*
|
||||
* 走 `code()`(剥注释)。⚠️ 判的是**结构**,不是字面:
|
||||
* 下面用 `functionBody()` / `callArgs()` 按括号配对取方法体与调用实参,
|
||||
* **不用** `\{[\s\S]{0,80}` 那种固定宽度窗口 —— `CRITERIA.md` §1 记着
|
||||
* 那个形状被"往规则里加一行注释"绕过第三次了。
|
||||
*
|
||||
* ## 边界 / 未做
|
||||
*
|
||||
* · 这些都是**静态**断言,证明"守卫被写下来了",**不证明它在运行时生效**
|
||||
* (那要真起一个窗口点链接)。这是本条与 §6.5「运行时行为要实测」的分工。
|
||||
* · 不判 `webPreferences` 那三项的**值**是否"足够安全"——那属于安全评审,
|
||||
* 不属于可机检的范围;本条只钉"不许被放松"。
|
||||
*/
|
||||
|
||||
import { code } from './lib/read.mjs';
|
||||
import { check, finish } from './lib/checks.mjs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const MAIN = join(HERE, '..', 'electron', 'main.cjs');
|
||||
const src = code(MAIN);
|
||||
|
||||
/**
|
||||
* 按括号配对取一个函数的**方法体**(跳过字符串与嵌套括号组)。
|
||||
*
|
||||
* 为什么不用固定宽度窗口:`CRITERIA.md` §1 的第 ③ 次露头 ——
|
||||
* ArkTS/JS 的链式修饰符与嵌套对象会让"往前看一个字符是不是 `}`"失效,
|
||||
* 而 `{[\s\S]{0,80}` 会被"加一行注释"绕过。
|
||||
*/
|
||||
function functionBody(srcText, name) {
|
||||
const decl = new RegExp(`function\\s+${name}\\s*\\([^)]*\\)\\s*\\{`).exec(srcText);
|
||||
if (!decl) return null;
|
||||
const open = decl.index + decl[0].length - 1;
|
||||
let depth = 0;
|
||||
let i = open;
|
||||
while (i < srcText.length) {
|
||||
const ch = srcText[i];
|
||||
if (ch === '"' || ch === "'" || ch === '`') { // 跳过字符串
|
||||
const q = ch;
|
||||
i += 1;
|
||||
while (i < srcText.length && srcText[i] !== q) {
|
||||
if (srcText[i] === '\\') i += 1;
|
||||
i += 1;
|
||||
}
|
||||
} else if (ch === '{') depth += 1;
|
||||
else if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return srcText.slice(open + 1, i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 取某个调用/赋值右侧**第一个实参**(按括号配对,跳过嵌套) */
|
||||
function firstArg(srcText, callRe) {
|
||||
const m = callRe.exec(srcText);
|
||||
if (!m) return null;
|
||||
const start = m.index + m[0].length;
|
||||
let i = start;
|
||||
let depth = 0;
|
||||
while (i < srcText.length) {
|
||||
const ch = srcText[i];
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
const q = ch;
|
||||
i += 1;
|
||||
while (i < srcText.length && srcText[i] !== q) {
|
||||
if (srcText[i] === '\\') i += 1;
|
||||
i += 1;
|
||||
}
|
||||
} else if (ch === '(' || ch === '{' || ch === '[') depth += 1;
|
||||
else if (ch === ')' || ch === '}' || ch === ']') {
|
||||
if (depth === 0) return srcText.slice(start, i);
|
||||
depth -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── ① 新窗口一律拒绝 ────────────────────────────────────────────────────────
|
||||
check('setWindowOpenHandler 存在(不给陌生站点一个带 preload 的窗口)',
|
||||
src.includes('setWindowOpenHandler'),
|
||||
'main.cjs 里 grep 不到 setWindowOpenHandler —— 邮件里的链接能开出一个继承 preload 上下文的新窗口');
|
||||
|
||||
/*
|
||||
* ★ 第一版这个正则匹配到的是**空串**(我写错了,不是源码缺东西):
|
||||
* `/setWindowOpenHandler\s*\(\s*\{[\s\S]*?\}\s*=>\s*\{/` —— `\{[\s\S]*?\}` 在
|
||||
* 参数解构 `({ url }) => {` 上先匹到了 `{ url }` 的那个 `}`,
|
||||
* 于是 `start` 落在 `=> {` 之后、深度已为 0 ⇒ firstArg 立刻返回空串。
|
||||
* ⇒ 改用**先定位调用、再取整个 handler 函数的体**,与 ④ 用同一个手段。
|
||||
*/
|
||||
const openHandlerBody = (() => {
|
||||
const at = src.indexOf('setWindowOpenHandler');
|
||||
if (at < 0) return null;
|
||||
const arrow = src.indexOf('=>', at);
|
||||
if (arrow < 0) return null;
|
||||
const open = src.indexOf('{', arrow);
|
||||
if (open < 0) return null;
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i += 1) {
|
||||
if (src[i] === '{') depth += 1;
|
||||
else if (src[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(open + 1, i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
check('setWindowOpenHandler 返回 { action: \'deny\' }',
|
||||
openHandlerBody !== null && /action\s*:\s*'deny'/.test(openHandlerBody),
|
||||
`必须显式 deny。取到的 handler 体:${JSON.stringify((openHandlerBody || '').slice(0, 80))}`);
|
||||
|
||||
// ── ② 窗口内导航一律拦下,且放行本应用自己的加载 ────────────────────────────
|
||||
check("will-navigate 被拦(主窗口不能被邮件里的链接带走)",
|
||||
src.includes("'will-navigate'"),
|
||||
'没有 will-navigate ⇒ loadFile 下点 <a href> 会直接导航走,token 暴露在陌生站点上');
|
||||
|
||||
const willNavBody = /'will-navigate'[\s\S]{0,400}?=>\s*\{([\s\S]*?)\n\s*\}/.exec(src);
|
||||
check('will-navigate 里调了 event.preventDefault()',
|
||||
willNavBody !== null && /preventDefault\s*\(\s*\)/.test(willNavBody[1]),
|
||||
'拦了事件却不 preventDefault,等于没拦');
|
||||
|
||||
/*
|
||||
* ★★ 必须**放行本应用自己的加载**,否则首屏进不去(prod 是 `file://`,
|
||||
* dev 是 `ELECTRON_START_URL`)。这是"别把守卫写死"的对称约束:
|
||||
* 只会 preventDefault 的实现会把应用自己锁死。
|
||||
*
|
||||
* ★★ 第三版才抓得住:前两版扫的是**整个文件**(`/isOwnLoad|startsWith\(…\)/.test(src)`),
|
||||
* 而变异把**调用点**改成 `if (false) return;` —— 变量**定义还在**文件里 ⇒ 照样绿。
|
||||
* ⇒ 必须扫 **will-navigate 的那个函数体**,且要求**真的被 return 掉**
|
||||
* (`return` 出现在守卫变量的判断上,而不是只判断"这个变量在文件里存在")。
|
||||
*/
|
||||
const willNavFn = (() => {
|
||||
const at = src.indexOf("'will-navigate'");
|
||||
if (at < 0) return null;
|
||||
const open = src.indexOf('{', src.indexOf('=>', at));
|
||||
if (open < 0) return null;
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i += 1) {
|
||||
if (src[i] === '{') depth += 1;
|
||||
else if (src[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(open + 1, i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
check('will-navigate 放行本应用自己的加载(prod 的 file:// / dev 的 DEV_URL)',
|
||||
willNavFn !== null
|
||||
&& /if\s*\(\s*isOwnLoad\s*\)\s*return/.test(willNavFn)
|
||||
&& /startsWith\(\s*'file:\/\/'\s*\)|startsWith\(\s*DEV_URL\s*\)/.test(willNavFn),
|
||||
'拦了 will-navigate 却没有**真的 return** 掉本应用自己的加载 ⇒ 首屏进不去。这类"过严"的守卫同样是缺陷');
|
||||
|
||||
// ── ③ 外跳只放行 http(s) ────────────────────────────────────────────────────
|
||||
const openExtBody = functionBody(src, 'openExternalSafely');
|
||||
check('openExternalSafely 存在(外跳的判定集中在一处)',
|
||||
openExtBody !== null,
|
||||
'外跳判定应当集中在一个函数里,而不是散在两个 handler 中各写一遍');
|
||||
|
||||
check('外跳只放行 http/https',
|
||||
openExtBody !== null
|
||||
&& /protocol\s*===\s*'http:'\s*\|\|/.test(openExtBody.replace(/\s+/g, ' '))
|
||||
&& /protocol\s*===\s*'https:'/.test(openExtBody),
|
||||
"只放行 http(s)。file: / javascript: / 自定义协议交给 shell.openExternal 意图不可控");
|
||||
|
||||
check('外跳对非法 URL 静默拒绝(不 try/catch 就裸 new URL)',
|
||||
openExtBody !== null && /catch/.test(openExtBody),
|
||||
'new URL() 对畸形输入会抛 ⇒ 不接住就会把守卫自己弄崩(崩了等于没守卫)');
|
||||
|
||||
// ── ④ 单实例锁:双击图标不该起两个进程 ──────────────────────────────────────
|
||||
check('requestSingleInstanceLock 被调用',
|
||||
src.includes('requestSingleInstanceLock'),
|
||||
'没有单实例锁 ⇒ 双击图标起两个进程:两条 SSE 连接 + 两套 accounts.json 并发写入');
|
||||
|
||||
const lockGuard = /if\s*\(\s*!\s*app\.requestSingleInstanceLock\s*\(\s*\)\s*\)/.test(src);
|
||||
check('拿不到锁就退出(而不是继续开第二个窗口)', lockGuard,
|
||||
'必须 app.quit() 并把启动流程放进 else 分支,否则"拿到锁"只是被算了一下');
|
||||
|
||||
/*
|
||||
* ★ 第一版我去 `createMainWindow` 的体里找 restore/show —— **找错了函数**:
|
||||
* `restore`/`show` 在 `second-instance` 的**回调**里,不在 createMainWindow 里。
|
||||
* (和 §12「与 X 一致」同族:要判的 X 侧那个值,得先知道它**在哪**。)
|
||||
*/
|
||||
const secondInstanceBody = (() => {
|
||||
const at = src.indexOf("'second-instance'");
|
||||
if (at < 0) return null;
|
||||
const open = src.indexOf('{', src.indexOf('=>', at));
|
||||
if (open < 0) return null;
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i += 1) {
|
||||
if (src[i] === '{') depth += 1;
|
||||
else if (src[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(open + 1, i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
check("second-instance 把已有窗口叫到前面(不是再建一个)",
|
||||
secondInstanceBody !== null
|
||||
&& /isMinimized\s*\(\s*\)/.test(secondInstanceBody)
|
||||
&& /restore\s*\(\s*\)/.test(secondInstanceBody)
|
||||
&& /show\s*\(\s*\)/.test(secondInstanceBody),
|
||||
'second-instance 里要把窗口 un-minimize + show,否则双击后用户看不到任何反应');
|
||||
|
||||
// ── ⑤ webPreferences 不许被放松(本条只钉"不许松",不评审"够不够安全")──
|
||||
const winBody = functionBody(src, 'createMainWindow');
|
||||
check('contextIsolation 仍为 true',
|
||||
winBody !== null && /contextIsolation\s*:\s*true/.test(winBody),
|
||||
'contextIsolation: false 会让页面脚本摸到 preload 暴露的接口');
|
||||
|
||||
check('nodeIntegration 仍为 false',
|
||||
winBody !== null && /nodeIntegration\s*:\s*false/.test(winBody),
|
||||
'nodeIntegration: true 等于把 Node 交给邮件正文里的一切');
|
||||
|
||||
check('sandbox 仍为 true',
|
||||
winBody !== null && /sandbox\s*:\s*true/.test(winBody),
|
||||
'sandbox: false 会撤掉 renderer 的进程级隔离');
|
||||
|
||||
finish('主进程安全防线');
|
||||
@ -211,7 +211,14 @@ const SUITE = [
|
||||
*/
|
||||
['test/inbox-fallback-poll.test.mjs', ['--experimental-strip-types', '--no-warnings'], 0],
|
||||
['test/sse-credentials.test.mjs', ['--experimental-strip-types', '--no-warnings'], 0],
|
||||
['test/web-comment-only.test.mjs', ['--experimental-strip-types', '--no-warnings'], 0]
|
||||
['test/web-comment-only.test.mjs', ['--experimental-strip-types', '--no-warnings'], 0],
|
||||
/*
|
||||
* ★ 2026-10-03 补接线:主进程安全防线(新建判据,**必须接线**,否则自检 2 会停掉全部)。
|
||||
* 登记数 **14**(自报 RESULT pass/fail 的判据,按 `§6.6` 逐条核对过)。
|
||||
* 守的是 `electron/main.cjs`:导航/新窗口拦截 + 单实例锁 + webPreferences 不许放松。
|
||||
* 详见该文件头部 —— 它与 `markdown-xss`(渲染层)防的是**两件不同的事**。
|
||||
*/
|
||||
['test/main-process-security.test.mjs', [], 14]
|
||||
];
|
||||
|
||||
// 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑)
|
||||
|
||||
Reference in New Issue
Block a user