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:
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
@ -154,6 +154,46 @@ describe('PermissionPanel 决策', () => {
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
/*
|
||||
* ★ 上一格测的是**渲染之后**的 disabled —— 它**抓不到本条要拦的形状**。
|
||||
* 那个形状发生在 React 提交**之前**:`busy` 还没变成 true,
|
||||
* 两次激活(双击 / Enter 连按 / 鼠标+键盘同时)都读到 `busy === false`。
|
||||
* 而审批是本工程里**唯一带副作用且不可撤销**的动作
|
||||
* ⇒ 服务端记两次账、Agent 侧收两条通知,且没有撤销入口。
|
||||
*
|
||||
* ★★ 这里踩过一次**假绿**,值得把形状记准(2026-10-03 实测):
|
||||
* 我第一版写 `fireEvent.click(b); fireEvent.click(b);`(不在 `act` 里),
|
||||
* 断言 `toHaveBeenCalledTimes(1)` —— **删掉闸门它也照样绿**。
|
||||
* 真因:两次 `fireEvent` 之间 React 会提交一次(自动批处理),
|
||||
* 于是第二次点到的按钮**已经 disabled** ⇒ 只发一条,与闸门无关。
|
||||
* ⇒ 必须把两次点击放进**同一个 `act`**(同一批次、中间不提交)才复现。
|
||||
* 实测对照(闸门已删):`A calls = 2`(同 act 内两次 click)。
|
||||
* `userEvent.click` 每次都 await 一轮 ⇒ **它测不到同帧**,别用。
|
||||
*/
|
||||
it('★ 同一帧内的两次激活只发出一条 decidePermission(busy 要到提交后才为真)', async () => {
|
||||
let release: (v: any) => void = () => {};
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockReturnValue(
|
||||
new Promise(res => {
|
||||
release = res;
|
||||
}) as any
|
||||
);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
const approve = screen.getByRole('button', { name: /同意/ });
|
||||
// ★ 同**一个** act:两次激活落在同一批次,中间没有 React 提交
|
||||
await act(async () => {
|
||||
fireEvent.click(approve);
|
||||
fireEvent.click(approve);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
release({ status: 'decided' });
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('提交失败时恢复可点,不假装已决策', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockRejectedValue(new Error('500'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
Reference in New Issue
Block a user