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:
2026-10-03 11:33:51 +08:00
parent 9d40816230
commit 4a01297dfe
6 changed files with 406 additions and 15 deletions

View File

@ -12,7 +12,7 @@
* - 只通过 contextBridge 暴露必要 API,不放开 nodeIntegration
*/
const { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain } = require('electron');
const { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain, shell } = require('electron');
const path = require('node:path');
const fs = require('node:fs');
@ -29,6 +29,31 @@ let mainWindow = null;
let tray = null;
/** 创建主窗口 */
/**
* ★ 只放行 http(s),其余一律**不**外跳。
*
* 为什么需要这一层:`loadFile(dist/index.html)` 下,邮件正文里的链接
* (`react-markdown` 会渲染 `<a href>`)点下去会**在应用窗口里导航走**。
* 渲染层的 Markdown 纪律是好的(无 `dangerouslySetInnerHTML`、无 `rehype-raw`),
* **目前没有 XSS 面** —— 但一个 `href="https://…"` 就足以把整个应用窗口
* 变成浏览器:窗口标题、preload 注入的 API base/token 全部暴露在陌生站点上。
*
* ★ 只放行 http(s),**不要**放行 `file:`、`javascript:`、自定义协议:
* `shell.openExternal` 对某些 scheme 会交给系统处理,意图不可控。
* 判不准就**什么都不做**(比"猜一个能用的 scheme"安全)。
*/
function openExternalSafely(rawUrl) {
let u;
try {
u = new URL(rawUrl);
} catch {
return; // 不是合法 URL ⇒ 静默拒绝
}
if (u.protocol === 'http:' || u.protocol === 'https:') {
void shell.openExternal(u.toString());
}
}
function createMainWindow() {
// 通过 additionalArguments 把 API 基地址与 token 传给 preload(preload 从 renderer 的 process.argv 末尾读)
// 传入的是透传 arg,用户页面不能直接改,preload 能读到
@ -57,6 +82,30 @@ function createMainWindow() {
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
}
/*
* ★ 新窗口一律**拒绝**,并把地址交给系统浏览器。
* `deny` 之外没有第二种选择:`allow` 会给那个站点一个带 preload 的窗口。
*/
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
openExternalSafely(url);
return { action: 'deny' };
});
/*
* ★ 窗口内导航一律**拦下**。注意要放行应用自己的那一次加载
* (`loadFile` / dev 时的 `loadURL`),否则首屏就进不去。
* 实现上不比较「当前 url」,而是放行**本应用自己的源**:
* —— `file:` 下比对 origin 不稳(各平台不一致),所以按前缀判。
*/
mainWindow.webContents.on('will-navigate', (event, url) => {
const isOwnLoad = isDev
? url.startsWith(DEV_URL)
: url.startsWith('file://');
if (isOwnLoad) return;
event.preventDefault();
openExternalSafely(url);
});
// 点关闭按钮时隐藏到托盘而不是退出(Phase 4 行为,但用户要求进托盘,直接做)
mainWindow.on('close', (e) => {
if (!app.isQuitting) {
@ -111,16 +160,35 @@ function createTray() {
}
// Electron 生命周期
app.whenReady().then(() => {
createMainWindow();
createTray();
app.on('activate', () => {
// macOS 点击 Dock 图标重新建窗口
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
else if (mainWindow) mainWindow.show();
/*
* ★ 单实例锁。没有它,双击图标会起**两个进程**:两个窗口、**两条 SSE 连接**、
* 两套 `accounts.json` 写入 —— 后两者是真问题:账号文件是「tmp + rename」
* 原子写(见 accountsFile 的注释),并发写就变成「后写的赢」,
* 而用户以为只有一扇窗。
* 第二个实例直接退出,把已有的窗口叫到前面来。
*/
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on('second-instance', () => {
// 第二个实例启动时:把已有的窗口叫到前面(而不是再建一个)
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
}
});
});
app.whenReady().then(() => {
createMainWindow();
createTray();
app.on('activate', () => {
// macOS 点击 Dock 图标重新建窗口
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
else if (mainWindow) mainWindow.show();
});
});
}
app.on('window-all-closed', () => {
// 托盘应用:关窗口不退出(macOS 惯例 + 用户要求的"进托盘"行为)