Files
MailUI4Agents/client/electron/test/main-process-security.test.mjs
JianFeeeee 4a01297dfe 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 是在**降低**强度的前提下加一层,值得单独一轮 + 真机验闪屏,不夹在本次。
2026-10-03 11:33:51 +08:00

242 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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('主进程安全防线');