## 为什么要这条(一个当场踩到、且 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 是否先红了。
308 lines
15 KiB
JavaScript
308 lines
15 KiB
JavaScript
/**
|
||
* 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('主进程安全防线'); |