/** * 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` 会渲染 * ``)点下去会**在应用窗口里导航走**: * 一个 `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 下点 会直接导航走,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('主进程安全防线');