// tests/unit/main/library-reader-root-guard.test.ts // // 守 r191「library:readFileForReader 必须过工作区白名单」契约。 // // 漏洞(本轮实证):`src/main/ipc/library.ts` 的 handler 只查 // `requireAbsolutePath`(是不是绝对路径),**不查是否落在工作区内** // ⇒ renderer 拿到任意绝对路径就能把字节读回来。 // // 同威胁模型的两条兄弟路径**都有**白名单,唯独 reader 这条漏了: // - rkms:// 协议(protocol.ts)→ assertInsideAllowedRoot → 403 // (注释原文:「防止 renderer 拿到任意路径都能读(iframe 内 // fetch().text() 可外泄 ~/.ssh/id_rsa)」) // - shell.open-path(ipc/shell.ts)→ assertInsideAllowedRoot → EPERM // ⇒ 读侧比 iframe 侧**更松**:一个被注入的 renderer 脚本可以读 // ~/.ssh/id_rsa / 浏览器 cookie / 任何用户文件,并把字节发出去。 // // 注:protocol.ts 的 import 注释写着「protocol.ts / shell.ts / reader.ts // 三处独立写…」—— 以为 reader.ts 也在用同一份 assertInsideAllowedRoot, // 实际 reader.ts 根本没接 ⇒ 注释与实现的漂移正是本漏洞的来源。 // // 本测锁: // 1. 工作区**外**的绝对路径 → 拒绝(ok:false / EPERM),且**不返回任何字节**; // 2. 工作区**内**的文件 → 照常读出字节(不回归); // 3. 工作区内的 symlink 指向区外 → 拒绝(白名单的 lstat + realpath 链防线)。 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { promises as fs } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; const electronMock = vi.hoisted(() => { const handlers = new Map unknown>(); return { handlers, ipcMain: { handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => { handlers.set(channel, fn); }, }, shell: { trashItem: vi.fn() }, }; }); vi.mock('electron', () => ({ ipcMain: electronMock.ipcMain, shell: electronMock.shell, })); import { setRootPath } from '../../../src/main/utils/safe-write'; import { registerLibraryIPC } from '../../../src/main/ipc/library'; import { IPC } from '../../../src/shared/types'; interface IpcOk { ok: boolean; data?: T; code?: string; message?: string; } /** ReadFileForReaderResult —— data / size / mtime(字节在 .data 里)。 */ interface ReadPayload { data: Uint8Array; size: number; mtime: number; } describe('library:readFileForReader 的工作区白名单(r191)', () => { let root: string; let outside: string; beforeEach(async () => { // 两个**互不相干**的临时目录 —— outside 不在 root 之下 root = await fs.mkdtemp(join(tmpdir(), 'rkms-reader-root-')); outside = await fs.mkdtemp(join(tmpdir(), 'rkms-reader-out-')); setRootPath(root); electronMock.handlers.clear(); registerLibraryIPC(); }); afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); await fs.rm(outside, { recursive: true, force: true }); }); function handler(): (event: unknown, ...args: unknown[]) => unknown { const h = electronMock.handlers.get(IPC.LIBRARY_READER_FOR_FILE); expect(h).toBeTruthy(); return h!; } it('工作区外的文件 → 拒绝,不返回任何字节', async () => { const secret = join(outside, 'id_rsa'); await fs.writeFile(secret, 'PRIVATE-KEY-MATERIAL', 'utf-8'); const res = (await handler()({}, secret)) as IpcOk; expect(res.ok).toBe(false); expect(res.code).toBe('EPERM'); // 关键:失败时**不能**带 data(否则报错路径也漏字节) expect(res.data).toBeUndefined(); expect(JSON.stringify(res)).not.toContain('PRIVATE-KEY-MATERIAL'); }); it('工作区内的文件 → 照常读出字节(不回归)', async () => { const litDir = join(root, 'literature'); await fs.mkdir(litDir, { recursive: true }); const file = join(litDir, 'note.txt'); await fs.writeFile(file, 'hello workspace', 'utf-8'); const res = (await handler()({}, file)) as IpcOk; expect(res.ok).toBe(true); expect(Buffer.from(res.data!.data).toString('utf-8')).toBe('hello workspace'); expect(res.data!.size).toBe('hello workspace'.length); }); it('工作区内的 symlink 指向区外 → 拒绝', async () => { const litDir = join(root, 'literature'); await fs.mkdir(litDir, { recursive: true }); const secret = join(outside, 'id_rsa'); await fs.writeFile(secret, 'PRIVATE-KEY-MATERIAL', 'utf-8'); const link = join(litDir, 'link.txt'); try { // Windows 上建 symlink 需要特权(Developer Mode / 管理员)—— 建不出来 // 就跳过这一条(其余两条已经锁住主契约)。 await fs.symlink(secret, link, 'file'); } catch { return; } const res = (await handler()({}, link)) as IpcOk; expect(res.ok).toBe(false); expect(res.code).toBe('EPERM'); expect(JSON.stringify(res)).not.toContain('PRIVATE-KEY-MATERIAL'); }); });