Skip to content

Commit af1b353

Browse files
committed
fix(client): 客户端低危修复——文件读写限制/更新状态机/崩溃恢复/迁移可诊断/preload白名单统一
- CL-L1: fs:read-file 禁止读取数据库文件(keban.db*)+ 50MB 大小限制 - CL-L2: ai:set-gateway-url 拒绝自定义端口与路径(CSP connect-src 动态放行的攻击面收窄) - CL-L3: backup:save 校验 JSON 内容与 100MB 上限 - CL-L4: 自动更新状态机(idle/checking/available/downloading/downloaded/error)——install 仅 downloaded 态、download 仅 available 态,防止任意时机触发退出安装与并发下载 - CL-L5: chatRepository.getMessages limit 钳制 1-200 - CL-L6: preload 专用监听 API(onWindowClosing/onMaximizedChanged/onSyncBeforeQuit)纳入 ALLOWED_EVENT_CHANNELS 校验 - CL-L7: schema ALTER 区分 duplicate column(幂等成功)与其他错误(记日志且不推进 user_version,保留重试机会) - CL-L8: video_record_stopped 确认事件校验绑定窗口 sender - CL-L9: ai:stream 活跃流在发起窗口销毁时 abort 清理 - CL-L10: uncaughtException 崩溃恢复——DB checkpoint 落盘后 relaunch,不再带病运行 - CL-L11: saveAIConfigAction 网关地址同步失败抛错 + gatewaySyncError 状态可观测 验证: npm run lint (0 errors) + npm run test (875 passed) + npm run build 全部通过
1 parent b572994 commit af1b353

9 files changed

Lines changed: 167 additions & 27 deletions

File tree

‎client/electron/ai/streamHandler.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ export function registerStreamHandler(): void {
7575
const abortController = new AbortController();
7676
activeStreams.set(requestId, abortController);
7777

78+
// CL-L9: 发起流式请求的窗口销毁时清理活跃流——否则 for await 继续从
79+
// 网关拉取直到流结束/超时(默认 300s),多次开关窗口累积残留流
80+
const wc = event.sender;
81+
wc.once('destroyed', () => {
82+
const controller = activeStreams.get(requestId);
83+
if (controller) {
84+
controller.abort();
85+
activeStreams.delete(requestId);
86+
logger.info(`[AI] [stream] Window destroyed, aborted stream: requestId=${requestId}`);
87+
}
88+
});
89+
7890
// 50ms 节流缓冲
7991
let chunkBuffer = '';
8092
let throttleTimer: ReturnType<typeof setTimeout> | null = null;

‎client/electron/db/chatRepository.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,14 @@ export function insertMessage(msg: Omit<MessageRow, 'id' | 'created_at'> & { id?
8484

8585
export function getMessages(sessionId: string, limit = 50, before?: number): MessageRow[] {
8686
const db = getConnection();
87+
// CL-L5: limit 钳制——渲染进程可传任意大值导致一次拉取整表消息经 IPC 全量传输
88+
const safeLimit = Math.min(Math.max(1, Math.floor(limit)), 200);
8789
if (before) {
8890
return db.prepare('SELECT * FROM assistant_messages WHERE session_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?')
89-
.all(sessionId, before, limit) as MessageRow[];
91+
.all(sessionId, before, safeLimit) as MessageRow[];
9092
}
9193
return db.prepare('SELECT * FROM assistant_messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?')
92-
.all(sessionId, limit) as MessageRow[];
94+
.all(sessionId, safeLimit) as MessageRow[];
9395
}
9496

9597
// ── 触发记录 ──────────────────────────────────────────────────

‎client/electron/db/schema.ts‎

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* @ai-context: SQLite 建表 DDL 唯一权威源——新增表需同步 dbIpcHandlers.ALLOWED_TABLES 白名单。
66
*/
77
import type Database from 'better-sqlite3';
8+
import { logger } from '../logger.js';
89

910
export const SCHEMA_VERSION = 8;
1011

@@ -231,12 +232,12 @@ export function initializeSchema(db: Database.Database): void {
231232
// v2 迁移:FSRS-5 扩展字段(条件 ALTER TABLE,幂等)
232233
const currentVersion = db.pragma('user_version', { simple: true }) as number;
233234
if (currentVersion < 2) {
234-
try {
235-
db.exec(`ALTER TABLE flashcards ADD COLUMN stability REAL DEFAULT NULL`);
236-
} catch { /* 列已存在 */ }
237-
try {
238-
db.exec(`ALTER TABLE flashcards ADD COLUMN difficulty REAL DEFAULT NULL`);
239-
} catch { /* 列已存在 */ }
235+
const ok = alterTableAddColumn(db, `ALTER TABLE flashcards ADD COLUMN stability REAL DEFAULT NULL`)
236+
&& alterTableAddColumn(db, `ALTER TABLE flashcards ADD COLUMN difficulty REAL DEFAULT NULL`);
237+
if (!ok) {
238+
logger.error('[Schema] v2 迁移失败,不设置 user_version,下次启动重试');
239+
return;
240+
}
240241
}
241242

242243
// v3 迁移:CRDT 同步引擎元数据表
@@ -246,12 +247,12 @@ export function initializeSchema(db: Database.Database): void {
246247

247248
// v4 迁移:search_index 表增加 entity_id 和 entity_type 列
248249
if (currentVersion < 4) {
249-
try {
250-
db.exec(`ALTER TABLE search_index ADD COLUMN entity_id TEXT`);
251-
} catch { /* 列已存在 */ }
252-
try {
253-
db.exec(`ALTER TABLE search_index ADD COLUMN entity_type TEXT`);
254-
} catch { /* 列已存在 */ }
250+
const ok = alterTableAddColumn(db, `ALTER TABLE search_index ADD COLUMN entity_id TEXT`)
251+
&& alterTableAddColumn(db, `ALTER TABLE search_index ADD COLUMN entity_type TEXT`);
252+
if (!ok) {
253+
logger.error('[Schema] v4 迁移失败,不设置 user_version,下次启动重试');
254+
return;
255+
}
255256
}
256257

257258
// v5 迁移:AI 助手会话/消息/触发表(CREATE IF NOT EXISTS 幂等)
@@ -266,13 +267,32 @@ export function initializeSchema(db: Database.Database): void {
266267
// v8 迁移:知识入籍——imports 表(DDL 已含在 SCHEMA_DDL);
267268
// notes/flashcards 增加 source_ref 溯源列(条件 ALTER TABLE,幂等,不破坏存量)
268269
if (currentVersion < 8) {
269-
try {
270-
db.exec(`ALTER TABLE notes ADD COLUMN source_ref TEXT`);
271-
} catch { /* 列已存在 */ }
272-
try {
273-
db.exec(`ALTER TABLE flashcards ADD COLUMN source_ref TEXT`);
274-
} catch { /* 列已存在 */ }
270+
const ok = alterTableAddColumn(db, `ALTER TABLE notes ADD COLUMN source_ref TEXT`)
271+
&& alterTableAddColumn(db, `ALTER TABLE flashcards ADD COLUMN source_ref TEXT`);
272+
if (!ok) {
273+
logger.error('[Schema] v8 迁移失败,不设置 user_version,下次启动重试');
274+
return;
275+
}
275276
}
276277

277278
db.pragma(`user_version = ${SCHEMA_VERSION}`);
278279
}
280+
281+
/**
282+
* CL-L7: 条件 ALTER TABLE ADD COLUMN——"列已存在"(幂等重试)静默视为成功,
283+
* 其他错误(SQLITE_BUSY/磁盘/权限)记录日志并返回 false,由调用方决定
284+
* 不推进 user_version(保留下次启动重试机会),避免迁移失败被永久掩盖。
285+
*/
286+
function alterTableAddColumn(db: Database.Database, sql: string): boolean {
287+
try {
288+
db.exec(sql);
289+
return true;
290+
} catch (err) {
291+
const msg = err instanceof Error ? err.message : String(err);
292+
if (/duplicate column/i.test(msg)) {
293+
return true; // 列已存在:幂等重试的正常情况
294+
}
295+
logger.error(`[Schema] ALTER TABLE 失败(非 duplicate column): ${msg}`);
296+
return false;
297+
}
298+
}

‎client/electron/main.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,20 @@ if (!gotTheLock) {
115115

116116
process.on('uncaughtException', (error) => {
117117
logger.crash('Uncaught Exception', error);
118+
// CL-L10: 主进程在未知异常后处于"带病运行"状态(数据库连接可能损坏、
119+
// 事件监听可能缺失);记录后执行崩溃恢复——checkpoint 落盘 + relaunch
120+
try {
121+
const { checkpointAndClose } = require('./db/sqliteService.js') as typeof import('./db/sqliteService.js');
122+
checkpointAndClose();
123+
logger.info('[Main] Database checkpointed during crash recovery');
124+
} catch (checkpointErr) {
125+
logger.error('[Main] Checkpoint during crash recovery failed', checkpointErr);
126+
}
127+
// 延迟重启,确保崩溃日志已落盘
128+
setTimeout(() => {
129+
app.relaunch();
130+
app.exit(1);
131+
}, 500);
118132
});
119133

120134
process.on('unhandledRejection', (reason) => {
@@ -225,6 +239,15 @@ if (!gotTheLock) {
225239
if (parsedUrl.protocol !== 'https:' && parsedUrl.hostname !== 'localhost' && parsedUrl.hostname !== '127.0.0.1') {
226240
throw new Error('生产环境必须使用 HTTPS');
227241
}
242+
// CL-L2: 端口与路径收紧——同一受信任域名的任意端口/路径都会被 CSP
243+
// connect-src 动态放行,同域攻击者可诱导请求到非预期端点;路径后缀
244+
// 还会污染后续 ${base}${apiPath} 拼接
245+
if (parsedUrl.port !== '') {
246+
throw new Error('不允许自定义端口,请使用默认端口');
247+
}
248+
if (parsedUrl.pathname !== '/' && parsedUrl.pathname !== '') {
249+
throw new Error('不允许自定义路径');
250+
}
228251

229252
await setRuntimeGatewayUrl(url);
230253
return { success: true };

‎client/electron/preload.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
218218
console.warn(`[preload] 不允许的发送 channel: ${channel}`);
219219
}
220220
},
221-
/** 监听主进程发出的窗口关闭事件 */
221+
/** 监听主进程发出的窗口关闭事件(CL-L6: 统一纳入 ALLOWED_EVENT_CHANNELS 校验) */
222222
onWindowClosing: (callback: () => void) => {
223+
if (!(ALLOWED_EVENT_CHANNELS as readonly string[]).includes('window:closing')) {
224+
console.warn('[preload] 不允许的事件 channel: window:closing');
225+
return () => {};
226+
}
223227
const handler = () => callback();
224228
ipcRenderer.on('window:closing', handler);
225229
return () => ipcRenderer.removeListener('window:closing', handler);
@@ -234,12 +238,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
234238
windowClose: () => ipcRenderer.invoke('window:close'),
235239
windowIsMaximized: () => ipcRenderer.invoke('window:isMaximized') as Promise<boolean>,
236240
onMaximizedChanged: (callback: (isMaximized: boolean) => void) => {
241+
if (!(ALLOWED_EVENT_CHANNELS as readonly string[]).includes('window:maximized-changed')) {
242+
console.warn('[preload] 不允许的事件 channel: window:maximized-changed');
243+
return () => {};
244+
}
237245
const handler = (_event: unknown, isMaximized: boolean) => callback(isMaximized);
238246
ipcRenderer.on('window:maximized-changed', handler);
239247
return () => ipcRenderer.removeListener('window:maximized-changed', handler);
240248
},
241-
/** 监听退出前同步事件 */
249+
/** 监听退出前同步事件(CL-L6: 统一纳入 ALLOWED_EVENT_CHANNELS 校验) */
242250
onSyncBeforeQuit: (callback: () => void) => {
251+
if (!(ALLOWED_EVENT_CHANNELS as readonly string[]).includes('sync:before-quit')) {
252+
console.warn('[preload] 不允许的事件 channel: sync:before-quit');
253+
return () => {};
254+
}
243255
const handler = () => callback();
244256
ipcRenderer.on('sync:before-quit', handler);
245257
return () => ipcRenderer.removeListener('sync:before-quit', handler);

‎client/electron/storageIpcHandlers.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ export function registerStorageIpcHandlers(): void {
5858
throw new Error('不允许读取该路径的文件');
5959
}
6060

61+
// CL-L1: 禁止直接读取数据库文件(keban.db*)——整库读取是应用内数据泄露面;
62+
// 大小限制 50MB,防止大文件读取占用主进程内存与 IPC 带宽
63+
const fileName = path.basename(resolvedPath);
64+
if (fileName === DB_FILE_NAME || fileName.startsWith(DB_FILE_NAME + '.')) {
65+
throw new Error('不允许直接读取数据库文件');
66+
}
67+
const { stat } = await import('fs/promises');
68+
const MAX_READ_SIZE = 50 * 1024 * 1024;
69+
const fileStat = await stat(resolvedPath);
70+
if (fileStat.size > MAX_READ_SIZE) {
71+
throw new Error(`文件超过大小上限(50MB,当前 ${Math.ceil(fileStat.size / 1024 / 1024)}MB)`);
72+
}
73+
6174
const buffer = await readFile(resolvedPath);
6275
return buffer.buffer; // 返回 ArrayBuffer
6376
});
@@ -170,6 +183,18 @@ export function registerStorageIpcHandlers(): void {
170183
return { success: false, canceled: true, path: null };
171184
}
172185

186+
// CL-L3: 校验备份内容——仅接受 JSON 字符串且大小 ≤100MB,
187+
// 防止注入代码借"保存备份"对话框将任意内容覆盖写入文件
188+
const MAX_BACKUP_SIZE = 100 * 1024 * 1024;
189+
if (typeof data !== 'string' || data.length > MAX_BACKUP_SIZE) {
190+
return { success: false, canceled: false, path: null, error: '备份数据无效或超过 100MB 上限' };
191+
}
192+
try {
193+
JSON.parse(data);
194+
} catch {
195+
return { success: false, canceled: false, path: null, error: '备份数据不是合法 JSON' };
196+
}
197+
173198
try {
174199
await writeFile(result.filePath, data, 'utf-8');
175200
return { success: true, canceled: false, path: result.filePath };

‎client/electron/updater.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ const DOWNLOAD_RETRY_DELAYS = [30_000, 60_000, 120_000]; // 30s, 60s, 120s
6767
/** 标记当前是否正在下载(用于区分 error 事件来源) */
6868
let isDownloading = false;
6969

70+
/**
71+
* CL-L4: 更新状态机——主进程侧状态校验,防止渲染层在任意时机调用
72+
* install(会立即退出应用)或重复触发 download(并发下载)
73+
*/
74+
type UpdateState = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'error';
75+
let updateState: UpdateState = 'idle';
76+
77+
function setUpdateState(state: UpdateState): void {
78+
updateState = state;
79+
logger.info(`[AutoUpdater] State → ${state}`);
80+
}
81+
7082
/**
7183
* 设置自动检查开关
7284
* 由 main.ts 的 IPC handler 调用
@@ -127,11 +139,13 @@ export function initAutoUpdater(mainWindow: BrowserWindow | null): void {
127139

128140
autoUpdater.on('checking-for-update', () => {
129141
logger.info('[AutoUpdater] Checking for update...');
142+
setUpdateState('checking');
130143
sendToRenderer(mainWindow, 'update-status', { status: 'checking' });
131144
});
132145

133146
autoUpdater.on('update-available', (info: UpdateInfo) => {
134147
logger.info(`[AutoUpdater] Update available: v${info.version}`);
148+
setUpdateState('available');
135149
sendToRenderer(mainWindow, 'update-status', {
136150
status: 'available',
137151
version: info.version,
@@ -141,10 +155,12 @@ export function initAutoUpdater(mainWindow: BrowserWindow | null): void {
141155

142156
autoUpdater.on('update-not-available', () => {
143157
logger.info('[AutoUpdater] No update available');
158+
setUpdateState('idle');
144159
sendToRenderer(mainWindow, 'update-status', { status: 'not-available' });
145160
});
146161

147162
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
163+
setUpdateState('downloading');
148164
sendToRenderer(mainWindow, 'update-status', {
149165
status: 'downloading',
150166
percent: Math.round(progress.percent),
@@ -157,6 +173,7 @@ export function initAutoUpdater(mainWindow: BrowserWindow | null): void {
157173
// 下载成功,重置重试计数
158174
downloadRetryCount = 0;
159175
isDownloading = false;
176+
setUpdateState('downloaded');
160177
sendToRenderer(mainWindow, 'update-status', {
161178
status: 'downloaded',
162179
version: info.version,
@@ -259,14 +276,25 @@ export function checkForUpdate(): void {
259276
}
260277

261278
export function downloadUpdate(): void {
279+
// CL-L4: 仅 available 态允许下载,防止重复触发并发下载
280+
if (updateState !== 'available') {
281+
logger.warn(`[AutoUpdater] downloadUpdate ignored (state=${updateState}, expected=available)`);
282+
return;
283+
}
262284
isDownloading = true;
263285
downloadRetryCount = 0;
286+
setUpdateState('downloading');
264287
autoUpdater.downloadUpdate().catch((err) => {
265288
logger.error('[AutoUpdater] Download failed', err);
266289
});
267290
}
268291

269292
export function installUpdate(): void {
293+
// CL-L4: 仅 downloaded 态允许安装——quitAndInstall 会立即退出应用并安装
294+
if (updateState !== 'downloaded') {
295+
logger.warn(`[AutoUpdater] installUpdate ignored (state=${updateState}, expected=downloaded)`);
296+
return;
297+
}
270298
autoUpdater.quitAndInstall();
271299
}
272300

‎client/electron/videoRecorder.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,12 @@ export class VideoRecorder {
182182
ipcMain.removeListener('video_record_stopped', onConfirmed);
183183
resolve();
184184
}, STOP_CONFIRM_TIMEOUT_MS);
185-
function onConfirmed(): void {
185+
function onConfirmed(event: Electron.IpcMainEvent): void {
186+
// CL-L8: 仅接受绑定窗口的确认——非绑定窗口可发送伪造事件提前结束
187+
// 等待窗口(与 SEC-005 sender 验证策略一致)
188+
if (win && !win.isDestroyed() && event.sender.id !== win.webContents.id) {
189+
return;
190+
}
186191
clearTimeout(timeoutId);
187192
resolve();
188193
}

‎client/src/stores/useSettingsStore.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ interface SettingsState {
4242
aiConfig: AIConfig;
4343
/** 音效设置(分类控制) */
4444
soundSettings: SoundSettings;
45+
/** CL-L11: 主进程网关地址同步失败信息(null 表示同步成功或未执行) */
46+
gatewaySyncError: string | null;
4547

4648
/** 更新 AI 配置(内存态) */
4749
setAIConfig: (config: AIConfig) => void;
@@ -51,8 +53,8 @@ interface SettingsState {
5153
*/
5254
updateSoundSettings: (partial: Partial<SoundSettings>) => void;
5355

54-
/** 保存 AI 配置到 localStorage */
55-
saveAIConfigAction: () => void;
56+
/** 保存 AI 配置到 localStorage(网关地址同步失败时抛错供 UI 提示) */
57+
saveAIConfigAction: () => Promise<void>;
5658
}
5759

5860
/** 初始化时加载音效设置并同步到 SoundPlayer */
@@ -62,6 +64,7 @@ soundPlayer.updateSettings(initialSoundSettings);
6264
export const useSettingsStore = create<SettingsState>((set, get) => ({
6365
aiConfig: getAIConfig(),
6466
soundSettings: initialSoundSettings,
67+
gatewaySyncError: null,
6568

6669
setAIConfig: (config) => set({ aiConfig: config }),
6770

@@ -78,13 +81,23 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
7881
persistSoundSettings(next);
7982
},
8083

81-
saveAIConfigAction: () => {
84+
saveAIConfigAction: async () => {
8285
const { aiConfig } = get();
8386
persistAIConfig(aiConfig);
8487
updateAIGatewayUrl(aiConfig.gatewayUrl);
8588
// 同步网关地址到 Electron 主进程(主进程无法访问 localStorage)
8689
if (window.electronAPI) {
87-
window.electronAPI.invoke('ai:set-gateway-url', aiConfig.gatewayUrl).catch(() => {});
90+
try {
91+
await window.electronAPI.invoke('ai:set-gateway-url', aiConfig.gatewayUrl);
92+
set({ gatewaySyncError: null });
93+
} catch (err) {
94+
// CL-L11: 主进程校验失败(URL 格式/域名/HTTPS/端口/路径)必须让用户感知,
95+
// 否则 UI 显示"已保存"但实际仍用旧网关地址——AI 功能静默异常且难以定位
96+
const message = err instanceof Error ? err.message : String(err);
97+
console.error('[Settings] 网关地址同步失败:', message);
98+
set({ gatewaySyncError: message });
99+
throw new Error(`网关地址保存失败:${message}`);
100+
}
88101
}
89102
},
90103
}));

0 commit comments

Comments
 (0)