This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
// tests/unit/main/ipc/settings-update-whitelist.test.ts
//
// 守 audit-r244「SETTINGS_UPDATE 只能改 UI 偏好字段」契约。
//
// 漏洞(本轮实证):`src/main/ipc/settings.ts` 的 handler 把 renderer 传来的
// patch 直接交给 `sanitizeSettingsPatch` —— 而它只是 `sanitizeAppSettings`
// (settings/validate.ts,同一个**加载侧**净化器)的别名,接受任何
// 「绝对 + ≤4096 字符」的 rootPath / dataPath。
//
// 而主进程 `settings/store.ts` 的 updateSettings 是
// `const next = { ...base, ...patch }; await saveSettings(next);`
// ⇒ 被劫持 / XSS 的 renderer 调
// api.updateSettings({ rootPath: 'C:\\Windows' })
// 就能把伪造的 rootPath **落盘到 settings.json**。
//
// 后果(不是数据丢失 —— 没有文件 IO 跟着伪造值走,但 UI 全错):
// - 设置页渲染错的根;renderer 侧 deriveNotesPath(settings.rootPath)
// 指向工作区**之外**的目录;
// - 「打开数据目录」走 SHELL_OPEN_PATH → assertInsideAllowedRoot 对
// **真实**根校验 → EPERM ⇒ 按钮变死;
// - 脏值要等到下次启动 reconcileRootPath 才自愈。
//
// 本测锁:
// 1. rootPath / dataPath 被剥掉(**不落盘**);
// 2. theme / language / workspaceLayout 等 UI 偏好字段照常更新;
// 3. 未知字段(未来新增的、还没进白名单的)也被剥掉(默认拒绝,而不是
// 默认放行)—— 白名单语义。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
dialog: { showOpenDialog: vi.fn() },
BrowserWindow: { fromWebContents: vi.fn() },
}));
const storeMock = vi.hoisted(() => ({
getSettings: vi.fn(() => ({})),
updateSettings: vi.fn(async (patch: unknown) => patch),
}));
vi.mock('../../../../src/main/settings/store', () => storeMock);
vi.mock('../../../../src/main/settings/migration', () => ({
switchDataPath: vi.fn(),
restoreDefaultDataPath: vi.fn(),
}));
import { registerSettingsIPC } from '../../../../src/main/ipc/settings';
import { IPC } from '../../../../src/shared/types';
describe('audit-r244:SETTINGS_UPDATE 只允许 UI 偏好字段', () => {
beforeEach(() => {
electronMock.handlers.clear();
storeMock.updateSettings.mockClear();
registerSettingsIPC();
});
afterEach(() => {
vi.restoreAllMocks();
});
function handler(): (event: unknown, ...args: unknown[]) => unknown {
const h = electronMock.handlers.get(IPC.SETTINGS_UPDATE);
expect(h).toBeTruthy();
return h!;
}
it('rootPath / dataPath 被剥掉,不进 updateSettings', async () => {
await handler()({}, {
rootPath: 'C:\\Windows',
dataPath: 'C:\\Windows',
theme: 'dark',
});
expect(storeMock.updateSettings).toHaveBeenCalledTimes(1);
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(patch.rootPath).toBeUndefined();
expect(patch.dataPath).toBeUndefined();
expect(patch.theme).toBe('dark');
});
it('非法 theme 的 patch 被净化层剥掉后仍是空 patch(不写盘)', async () => {
await handler()({}, { theme: 'not-a-theme', rootPath: 'C:\\Windows' });
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(Object.keys(patch)).toEqual([]);
});
it('UI 偏好字段照常更新(theme / language / workspaceLayout)', async () => {
await handler()({}, {
theme: 'system',
language: 'en-US',
workspaceLayout: { fontFamily: 'serif' },
});
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(patch.theme).toBe('system');
expect(patch.language).toBe('en-US');
expect(patch.workspaceLayout).toEqual({ fontFamily: 'serif' });
});
it('未知字段被剥掉(白名单语义 = 默认拒绝)', async () => {
await handler()({}, { enabledExtensions: { evil: true } });
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(patch.enabledExtensions).toBeUndefined();
});
});