Files
MailUI4Agents/client/electron/test/main-process-security.test.mjs
jianf d94717aa95 test(ele): ★★ preload 加载闸门 —— 一句类型标注能静默废掉整条桥
## 为什么要这条(一个当场踩到、且 tsc 抓不到的坑)

上一个提交给 `preload.cjs` 加窗口动作时,第一版写了 TS 类型标注:

    onMaximizedChanged: (cb: (maximized: boolean) => void) => {   // ← .cjs 里不能有

`preload.cjs` 是纯 JS,整个文件**静默**加载失败。症状链:

    hasBridge: false  hasWin: false  base: "/api/v1"  tok: <null>  store.n: 0
    text: "用户名 | 密码 | 登录"

  · 账号读不到 ⇒ 回退到**网页版**登录分支(用户名+密码,桌面壳里注定失败)
  · 标题栏不渲染 ⇒ 自绘窗口那条顶栏整个消失

★ 而 `npm run typecheck`(`tsc --noEmit`)**不检查 .cjs**,一路全绿。
我当时就是靠它判断「没问题」的 —— 它证明不了这件事。

## 为什么这条判据要「真的加载」而不是读文件看字符串

失效形状是「**静默少给一整条桥**」:不抛错、不警告,症状出现在**别处**
(看起来像「功能没做」而不是「代码坏了」)。

与 `93697c4`(stripComments 两趟正则吃掉 137 行真代码)同一族,但更狠 ——
那条至少还会让某条判据红;这条让**所有**判据都无从察觉
(大家都不读 preload,自然没人会发现它没加载)。

⇒ 所以闸门是 `new Module(...)._compile(code(PRELOAD), PRELOAD)`,
真的解析并执行到底(stub 掉 `electron` 模块)。
不是 `node -c`、不是正则、不是 `require`。

**变异验证**:把类型标注加回去 ⇒

    失败  ★ preload.cjs 能真的加载 — 实际错误:Unexpected token ':'
    主进程安全防线:18 通过,1 失败

★ 且验证了它在 `code()` 剥掉注释后**仍然有效**(剥注释会改行号,
但这条判的是「能不能解析执行」,不依赖行号)。

## 另 4 条(都属自绘窗口这一批)

* preload 不得暴露任意 IPC 透传(只有具名动作)
* 窗口三个动作都过了窄接口(`frame:false` 之后没有系统标题栏兵底)
* 最大化状态是订阅来的(`pushMaxState`:`maximize`/`unmaximize`/进退全屏)
* `frame: false` 与 `titleBarStyle: 'hidden'` 同时在

## 自报条数 14 → 19

`run-all.mjs` 的显式编辑(§6.6)。**这条是套件自己抓到的**,
不是我改的 —— 判据报「自报 19 条 > 登记的 14 条,新加的那几条不在
"被删会红"的保护内」。

## 顺带修一条自己的判据缺陷

新写的闸门一开始用了裸 `readFileSync` 读 preload,被
`criteria-hygiene` 抓(「判据目录里不得出现裸 readFileSync」)。
改成 `code()` —— 判「有没有这个符号」时要的是代码,裸读原文会被
解释性注释骗(同坑本仓踩过两次,其中一次就是这条判据自己)。

## 两份 GUI 复查报告一并存档

`docs/reviews/gui-review-2026-10-03.md`(基线 `5e312c6`,848 行)
`docs/reviews/gui-fixes-review-2026-10-03.md`(基线 `b4610f1`,575 行)

★ 第二份里**否证了上游报告两条**,避免下一任白做工:
  ① 鸿蒙日历「格/行时间基准不一致」是**误报** —— 5 个时区实测
     `hhmmAtOffset(ts, deviceOffsetMinutes)` 恒等于 `getHours()`;
  ② `MailStore.bump()` 不发布 AppStorage 是**设计**(混用会死循环),
     `harmony-state-review.md` #3 是误读。

★ 另外查出一条比那批修复更要紧的事:HarmonyOS 客户端**当前编译不过**
(`Theme.hairline` 从未定义,48 个提交前就坏了),而修复提交的信息写着
「BUILD SUCCESSFUL」—— 路径在本机不存在。已用 `git worktree` 对照旧基线
证明**不是这批提交引入的**。已在报告 §2/§5.2 记明并给了修法选项,
**未动业务代码**(等决策)。

## 本机实测读数(装依赖 + 补齐 4 个环境变量后)

    RESULT files=39 ran=39 checks=635 pass=623 fail=2 skip=10 red=5
           broken=0 unreported=0
           变异通道 mutants=52 ran=52 on_new_criteria=37
    npx vitest run   → Test Files 16 passed / Tests 271 passed
    npm run typecheck → 通过

剩 5 个红**全是环境**,不是缺陷:
  · `build-stamp`   —— 本机没 `npm run build` 时的产物
  · `harmony-push`  —— `agconnect-services.json` 不存在(该欠账的 `due` 就是"拿到真机")
  · `--exitcode-selftest` + 因果对照 + `summary.py baseline-residue`
    —— 三者**同一个根因**:本会话 uid=1000 非 root,`runuser`/`setpriv`
       都无法降权(实测 `runuser -u nobody` 报"非 root 用户不能使用")
       ⇒ `run-all.mjs:1043` 的 `dropTo` 探测返回 null ⇒ 那 6 个需要降权的
       变异案例被跳过并计红,连带 baseline 残留检测一起失效。
       ★ 那条报错文案写着「优先按变异残留查」会**把人引向查工作区**,
         而底本在 `mkdtempSync` 造的 /tmp 迷你仓库里、与工作区无关
         —— 已在报告 §4.3 记下,建议文案补一句先看 selftest 是否先红了。
2026-10-04 11:08:57 +08:00

308 lines
15 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';
import Module from 'node:module';
const HERE = dirname(fileURLToPath(import.meta.url));
const MAIN = join(HERE, '..', 'electron', 'main.cjs');
const PRELOAD = join(HERE, '..', 'electron', 'preload.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 的进程级隔离');
// ── ⑥ 自绘窗口外观(2026-10-04)──
//
// ★ 本段的第一条是本文件存在以来最重要的一条:**preload 能不能加载**。
// 2026-10-04 实测:在 preload.cjs(`.cjs`)里写了一句 TS 类型标注
// `(cb: (maximized: boolean) => void)` ⇒ **整个 preload 静默加载失败** ⇒
// 渲染层 `window.agentmail === undefined` ⇒
// · 账号读不到 → 回退到**网页版**登录页(用户名+密码,桌面壳里注定失败)
// · 标题栏不渲染 → 自绘窗口那条顶栏整个消失
// 而 `npm run typecheck`(tsc --noEmit)**不检查 .cjs**,照常全绿。
//
// ⇒ 失效形状是「静默少给一整条桥」:不抛错、不警告,症状出现在**别处**。
// 与 `stripComments` 吃掉 137 行真代码(见 93697c4)同一族,
// 但更狠 —— 那条至少还会让某条判据红,这条让**所有**判据都无从察觉。
//
// 所以加载闸门必须**真的 require 一次**,而不是读文件、看字符串。
// ★ 读 preload 走 `code()` 而不是裸 `readFileSync`(criteria-hygiene 的硬规矩):
// 判「有没有这个符号/这条约束」时要的是**代码**,裸读原文会被解释性注释骗
// (同一个坑本仓踩过两次 —— 其中一次就是它自己)。
// 下面的 `_compile` 用的 source 也用同一份,两处不会分叉。
const preloadSrc = code(PRELOAD);
let preloadLoads = true;
let preloadErr = '';
try {
// 桩掉 electron 模块:preload 只用 contextBridge / ipcRenderer 两个符号,
// 而它们的行为不是本条要验的(要验的是「这份文件能不能被解析并执行到底」)。
const stub = { contextBridge: { exposeInMainWorld: () => {} }, ipcRenderer: { on() {}, invoke() {}, removeListener() {} } };
const mod = new Module(PRELOAD, null);
mod.filename = PRELOAD;
mod.paths = Module._nodeModulePaths(dirname(PRELOAD));
mod.require = id => (id === 'electron' ? stub : mod.require(id));
mod._compile(preloadSrc, PRELOAD);
} catch (e) {
preloadLoads = false;
preloadErr = String(e && e.message || e);
}
check('★ preload.cjs 能真的加载(`node -c` 那种语法错在这里被抓住)',
preloadLoads,
'preload 加载失败是**静默**的:渲染层只看到 window.agentmail === undefined,' +
'于是账号读不到、标题栏不渲染,像「功能没做」而不是「代码坏了」。' +
'⚠️ tsc --noEmit 不检查 .cjs,所以 typecheck 全绿不代表这里没问题。' +
(preloadErr ? ` 实际错误:${preloadErr}` : ''));
check('preload 不得暴露任意 IPC 透传(只有具名动作)',
!/ipcRenderer\.invoke\s*\(\s*(?:channel|name|e)\s*[,)]/.test(preloadSrc),
'把 IPC 名当参数传进来 = 渲染层可以 invoke 任意主进程通道');
check('窗口三个动作都过了窄接口',
/minimize:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('window:minimize'\)/.test(preloadSrc) &&
/toggleMaximize:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('window:toggle-maximize'\)/.test(preloadSrc) &&
/close:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('window:close'\)/.test(preloadSrc),
'最小化/最大化/关闭三个动作缺一不可(frame:false 之后没有系统标题栏兵底)');
check('最大化状态是订阅来的(pushMaxState 把 maximize/unmaximize 推给渲染层)',
/mainWindow\.on\(\s*'maximize'/.test(src) && /mainWindow\.on\(\s*'unmaximize'/.test(src) &&
/maximized-changed/.test(src),
'最大化有三条不经过按钮的路径(双击拖拽区 / Win+↑↓ / 拖边缘 Snap),' +
'只靠按钮点击时查一次 ⇒ 图标与真实状态脱节');
check('frame: false 与 titleBarStyle: hidden 同时在(自绘窗口框架)',
winBody !== null && /frame\s*:\s*false/.test(winBody) && /titleBarStyle\s*:\s*'hidden'/.test(winBody),
'只给 titleBarStyle 仍留着系统边框;两者一起才是「整个框架自己接管」');
finish('主进程安全防线');