Skip to content

Commit b572994

Browse files
committed
fix(client): 客户端中危修复——IPC批处理返回值/流式超时与连接释放/代理路径校验/MCP最小环境/采集源校验/备份保护/watchdog上限
- CL-M1: ai:stream:start 的 API 路径白名单校验(仅 /api/v1/(ai|multimodal)/ 前缀,拒绝 ?/# 注入),堵住代理访问任意网关端点 - CL-M2: postJsonStream 读取阶段 30s 无数据超时(原仅覆盖建连)+ 所有退出路径 reader.cancel() 释放连接 + 外部 abort 信号检测 - CL-M3: safeHandleBatched 返回真实结果(最后一次调用结算 Promise),microtask 异常 reject 给调用方而非 unhandledRejection - CL-M4: 屏幕采集缩略图 1920×1080 → 1280×720(extractFrame 目标分辨率),多窗口每帧位图分配降 56% - CL-M5: db:batch 批量操作条数上限 1000,防止单事务长期持有写锁阻塞全部 db IPC - CL-M6: columnCache 失效策略——initialize/close/checkpointAndClose 时 clear,运行期 schema 变更不再过滤新列 - CL-M7: MCP Bridge 子进程最小环境白名单(14 个基础变量),不再透传宿主全部环境变量(供应链凭据泄露面) - CL-M8: displayMedia handler 校验请求 frame 来源(file:// 或 localhost),非应用来源拒绝授权(防静默录屏+系统音频) - CL-M9: dbFileMigrator 复制前检查目标目录已存在 keban.db 则拒绝(防覆盖旧库);备份名加时间戳并保留最近 5 份 - CL-M10: 帧超时 watchdog 连续重启上限 3 次(收到有效帧清零),耗尽后停止并 toast 提示,消除幽灵采集循环 - CL-M11: callWithLocalFallback 本地探测缓存过期时先刷新再决策,不再静默跳过本地推理 验证: npm run lint (0 errors) + npm run test (875 passed) + npm run build 全部通过
1 parent 1feb466 commit b572994

13 files changed

Lines changed: 245 additions & 27 deletions

‎client/electron/ai/gatewayHttp.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { randomUUID } from 'crypto';
1313
import { logger } from '../logger.js';
1414
import { gatewayUrl } from './gatewayConfig.js';
1515
import { isLocalInferenceEnabled } from './ollama/config.js';
16-
import { isOllamaAvailable } from './ollama/OllamaService.js';
16+
import { isOllamaAvailable, getOllamaStatus } from './ollama/OllamaService.js';
1717

1818
/** 构建公共请求头(JSON 模式含 Content-Type,multipart 由 fetch 自动生成) */
1919
function buildHeaders(clientRequestId: string, json: boolean, authToken?: string): Record<string, string> {
@@ -156,8 +156,23 @@ export async function callWithLocalFallback<TReq, TRes>(
156156
authToken?: string,
157157
timeoutMs: number = 60000,
158158
): Promise<{ data: TRes; source: 'local' | 'remote'; requestId?: string }> {
159-
// 检查本地 Ollama 是否可用
160-
if (isLocalInferenceEnabled() && isOllamaAvailable()) {
159+
// CL-M11: 本地探测缓存过期时先刷新一次再决策——isOllamaAvailable 对过期
160+
// 缓存直接返回 false,若仅因缓存过期而静默跳过本地推理,用户以为在用本地
161+
// 模型实际全走云端(隐私与成本预期偏差,且不可观测)
162+
let localAvailable = false;
163+
if (isLocalInferenceEnabled()) {
164+
localAvailable = isOllamaAvailable();
165+
if (!localAvailable) {
166+
try {
167+
const fresh = await getOllamaStatus(true);
168+
localAvailable = fresh.running;
169+
} catch {
170+
localAvailable = false; // 探测异常时安全降级远程
171+
}
172+
}
173+
}
174+
175+
if (localAvailable) {
161176
try {
162177
const localResult = await localHandler();
163178
logger.info(`[AI] ← Local Ollama success for ${apiPath}`);

‎client/electron/ai/gatewayStream.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,18 @@ export async function* postJsonStream<TReq>(
8787
let buffer = '';
8888
/** SSE 缓冲最大字节数,防止恶意服务端无分隔符输出耗尽内存 */
8989
const MAX_BUFFER_SIZE = 1 * 1024 * 1024; // 1MB
90+
/** CL-M2: 读取阶段无数据超时(ms)——原超时仅覆盖 fetch 建连阶段,
91+
* 服务端建连后挂起不发数据会导致读取永久等待 */
92+
const READ_TIMEOUT_MS = 30_000;
9093

9194
try {
9295
while (true) {
93-
const { done, value } = await reader.read();
96+
// CL-M2: 外部取消信号检查——abort 后立即停止消费,避免继续拉取
97+
if (externalSignal?.aborted) {
98+
break;
99+
}
100+
101+
let { done, value } = await readWithTimeout(reader, READ_TIMEOUT_MS);
94102
if (done) break;
95103

96104
buffer += decoder.decode(value, { stream: true });
@@ -136,6 +144,37 @@ export async function* postJsonStream<TReq>(
136144
}
137145
}
138146
} finally {
147+
// CL-M2: 所有退出路径([DONE] 提前 return/异常/取消)必须 cancel 响应流,
148+
// 否则底层 TCP 连接保持打开直到 GC(连接泄漏);cancel 对已自然结束的流为 no-op
149+
try {
150+
await reader.cancel();
151+
} catch {
152+
// 流已关闭时 cancel 抛错可忽略
153+
}
139154
reader.releaseLock();
140155
}
141156
}
157+
158+
/**
159+
* CL-M2: 带超时的流读取——读取阶段无数据超时(30s)时取消流并抛错,
160+
* 防止服务端建连后挂起导致调用方永久等待;超时同时 cancel reader
161+
* 释放底层 TCP 连接(否则挂起的 read() 永远不结束)。
162+
*/
163+
async function readWithTimeout(
164+
reader: ReadableStreamDefaultReader<Uint8Array>,
165+
timeoutMs: number,
166+
): Promise<ReadableStreamReadResult<Uint8Array>> {
167+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
168+
const timeoutPromise = new Promise<never>((_, reject) => {
169+
timeoutId = setTimeout(() => {
170+
// 取消流:让挂起的 read() 立即结束,并释放底层连接
171+
reader.cancel().catch(() => {});
172+
reject(new Error(`Stream read timeout after ${timeoutMs}ms`));
173+
}, timeoutMs);
174+
});
175+
try {
176+
return await Promise.race([reader.read(), timeoutPromise]);
177+
} finally {
178+
if (timeoutId) clearTimeout(timeoutId);
179+
}
180+
}

‎client/electron/ai/streamHandler.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ export function registerStreamHandler(): void {
5656
const { requestId, method, payload, authToken } = args;
5757
const sender = event.sender;
5858

59+
// CL-M1: API 路径白名单校验——method 实为路径,直接拼入 URL 可访问
60+
// 网关任意端点(含 ?/# 注入参数);仅允许受信任的 AI 功能路径
61+
const VALID_STREAM_PATH = /^\/api\/v1\/(ai|multimodal)\/[A-Za-z0-9_\/-]+$/;
62+
if (
63+
typeof method !== 'string' ||
64+
method.includes('?') ||
65+
method.includes('#') ||
66+
!VALID_STREAM_PATH.test(method)
67+
) {
68+
logger.warn(`[AI] [stream] 拒绝非法路径: ${method}`);
69+
return { ok: false, error: 'Invalid stream path' };
70+
}
71+
5972
logger.info(`[AI] [stream] Start: requestId=${requestId}, method=${method}`);
6073

6174
// 为该流式请求创建 AbortController

‎client/electron/db/dbFileMigrator.ts‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,19 @@ export async function migrateDatabaseFiles(
131131
const sourceStats = await stat(sourceDb);
132132
let totalSize = sourceStats.size;
133133

134+
// CL-M9: 复制前检查目标目录是否已有数据库——copyFile 默认直接覆盖,
135+
// 若用户选择已存在 keban.db 的目录(历史存储路径/手动复制残留),
136+
// 旧库会被静默替换且无备份,造成不可逆数据丢失
137+
const targetDb = path.join(targetDir, 'keban.db');
138+
if (await fileExists(targetDb)) {
139+
return {
140+
success: false,
141+
sourcePath: sourceDir,
142+
targetPath: targetDir,
143+
error: '目标目录已存在数据库文件(keban.db),为防止覆盖旧数据,请选择空目录或将旧库改名后重试',
144+
};
145+
}
146+
134147
// 计算所有存在的文件大小
135148
for (const fileName of DB_FILES) {
136149
const filePath = path.join(sourceDir, fileName);
@@ -227,15 +240,38 @@ export function verifyDatabaseIntegrity(dbPath: string): boolean {
227240

228241
/**
229242
* 为旧路径数据库创建备份
230-
* 将 keban.db / keban.db-wal / keban.db-shm 各复制为 .bak(同目录)
243+
* 将 keban.db / keban.db-wal / keban.db-shm 各复制为带时间戳的 .bak(同目录)
244+
* CL-M9: 备份名固定为 keban.db.bak 会被多次切换路径时互相覆盖,回滚点丢失;
245+
* 改为时间戳后缀并仅保留最近 5 份。
231246
*/
232247
export async function createBackup(dbDir: string): Promise<void> {
248+
const timestamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
233249
for (const fileName of DB_FILES) {
234250
const sourcePath = path.join(dbDir, fileName);
235-
const backupPath = path.join(dbDir, fileName + '.bak');
251+
const backupPath = path.join(dbDir, `${fileName}.${timestamp}.bak`);
236252
if (await fileExists(sourcePath)) {
237253
await copyFile(sourcePath, backupPath);
238-
logger.info(`[DBMigrator] Backup created: ${fileName}.bak`);
254+
logger.info(`[DBMigrator] Backup created: ${path.basename(backupPath)}`);
239255
}
240256
}
257+
// 清理超出保留份数的旧备份(按文件名时间戳排序,保留最近 5 份)
258+
await pruneOldBackups(dbDir);
259+
}
260+
261+
/** 仅保留最近 5 份 .bak 备份(按时间戳后缀排序) */
262+
async function pruneOldBackups(dbDir: string): Promise<void> {
263+
try {
264+
const { readdir } = await import('fs/promises');
265+
const entries = await readdir(dbDir);
266+
const backupFiles = entries
267+
.filter((name) => name.endsWith('.bak'))
268+
.sort()
269+
.reverse();
270+
for (const name of backupFiles.slice(5)) {
271+
await rm(path.join(dbDir, name));
272+
logger.info(`[DBMigrator] Pruned old backup: ${name}`);
273+
}
274+
} catch (err) {
275+
logger.warn(`[DBMigrator] Failed to prune old backups: ${err instanceof Error ? err.message : String(err)}`);
276+
}
241277
}

‎client/electron/db/dbIpcHandlers.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,16 @@ export function registerDbIpcHandlers(): void {
260260
safeHandle('db:batch', async (_event, params: { operations: Array<{ type: string; table: string; [key: string]: unknown }> }) => {
261261
const dbConn = getConnection();
262262

263+
// CL-M5: 批量操作条数上限——超大数组单事务同步执行会长期持有写锁
264+
// (所有其他 db:* IPC 排队)并阻塞主进程事件循环
265+
const MAX_BATCH_OPS = 1000;
266+
if (!Array.isArray(params.operations) || params.operations.length === 0) {
267+
throw new Error('[DB] Batch operations must be a non-empty array');
268+
}
269+
if (params.operations.length > MAX_BATCH_OPS) {
270+
throw new Error(`[DB] Batch operations exceed limit (max ${MAX_BATCH_OPS}, got ${params.operations.length})`);
271+
}
272+
263273
const txn = dbConn.transaction(() => {
264274
for (const op of params.operations) {
265275
const tableName = resolveTable(op.table as string);

‎client/electron/db/sqliteRepository.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ function q(col: string): string {
110110
/** 缓存每张表的列名白名单 */
111111
const columnCache = new Map<string, Set<string>>();
112112

113+
/**
114+
* CL-M6: 使列名白名单缓存失效——schema 变更(ALTER TABLE)或数据库
115+
* 连接重开(reinitialize 到新路径)后必须调用,否则 filterAllowedColumns
116+
* 会用旧列集合过滤掉新列,导致新列数据静默丢弃。
117+
*/
118+
export function clearColumnCache(): void {
119+
columnCache.clear();
120+
}
121+
113122
/**
114123
* 获取表的合法列名集合(从 PRAGMA table_info 查询并缓存)
115124
*/

‎client/electron/db/sqliteService.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import Database from 'better-sqlite3';
1212
import { logger } from '../logger.js';
13+
import { clearColumnCache } from './sqliteRepository.js';
1314

1415
// ================================================================
1516
// 模块级状态
@@ -48,6 +49,10 @@ export function initialize(dbPath?: string): Database.Database {
4849
try {
4950
db = new Database(resolvedPath);
5051

52+
// CL-M6: 新连接建立时使列名白名单缓存失效——重新初始化到新库/新路径
53+
// 后 schema 可能不同,旧缓存会把新列过滤掉导致数据静默丢弃
54+
clearColumnCache();
55+
5156
// 开启 WAL 模式:提升并发读写性能,减少锁竞争
5257
db.pragma('journal_mode = WAL');
5358
logger.info('[SQLite] WAL mode enabled');
@@ -102,6 +107,8 @@ export function close(): void {
102107
} finally {
103108
db = null;
104109
currentDbPath = null;
110+
// CL-M6: 连接关闭后缓存同步失效(下次 initialize 会重建)
111+
clearColumnCache();
105112
}
106113
}
107114

@@ -138,6 +145,8 @@ export function checkpointAndClose(): void {
138145
} finally {
139146
db = null;
140147
currentDbPath = null;
148+
// CL-M6: 连接关闭后缓存同步失效(下次 initialize 会重建)
149+
clearColumnCache();
141150
}
142151
}
143152
}

‎client/electron/displayMediaHandler.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* useClassroomAudio 的 openMicrophoneStream 实现,无需本模块参与。
1616
*/
1717

18-
import { session, desktopCapturer } from 'electron';
18+
import { session, desktopCapturer, type WebFrameMain } from 'electron';
1919
import { logger } from './logger';
2020

2121
/**
@@ -29,6 +29,18 @@ export function setPreferredDisplaySource(sourceId: string | null): void {
2929
preferredSourceId = sourceId;
3030
}
3131

32+
/**
33+
* CL-M8: 校验 displayMedia 请求来源是否为应用自身 frame。
34+
* 生产打包为 file:// 路径,开发为 http://localhost:端口。
35+
* 未通过校验一律拒绝——防止渲染层注入代码静默获取整屏 + 系统音频混音。
36+
*/
37+
function isTrustedFrame(frame: WebFrameMain | null): boolean {
38+
if (!frame) return false;
39+
const url = frame.url || '';
40+
if (url.startsWith('file://')) return true;
41+
return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\//.test(url);
42+
}
43+
3244
/**
3345
* 注册 displayMedia 请求处理器(须在 app.whenReady 后调用一次)
3446
*
@@ -37,7 +49,15 @@ export function setPreferredDisplaySource(sourceId: string | null): void {
3749
*/
3850
export function registerDisplayMediaHandler(): void {
3951
session.defaultSession.setDisplayMediaRequestHandler(
40-
(_request, callback) => {
52+
(request, callback) => {
53+
// CL-M8: 仅响应应用自身 frame 的请求——忽略 request 参数时
54+
// 任何来源(含注入 iframe/脚本)都能无感获取整屏画面 + 系统音频混音
55+
if (!isTrustedFrame(request.frame)) {
56+
const url = request.frame?.url ?? 'unknown';
57+
logger.warn(`[DisplayMedia] 拒绝非应用来源的捕获请求: ${url}`);
58+
callback({});
59+
return;
60+
}
4161
void (async () => {
4262
try {
4363
const sources = await desktopCapturer.getSources({

‎client/electron/ipcUtils.ts‎

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -151,26 +151,39 @@ export function safeHandleBatched<T extends any[]>(
151151
channel: string,
152152
handler: TypedHandler<T>,
153153
): void {
154-
let pendingArgs: { event: Electron.IpcMainInvokeEvent; args: T } | null = null;
154+
let pending: {
155+
event: Electron.IpcMainInvokeEvent;
156+
args: T;
157+
resolve: (value: unknown) => void;
158+
reject: (reason: unknown) => void;
159+
} | null = null;
155160
let scheduled = false;
156161

157162
const batchedHandler: TypedHandler<T> = (event, ...args) => {
158-
pendingArgs = { event, args };
159-
160-
if (!scheduled) {
161-
scheduled = true;
162-
queueMicrotask(async () => {
163-
scheduled = false;
164-
if (pendingArgs) {
165-
const { event: ev, args: a } = pendingArgs;
166-
pendingArgs = null;
167-
return handler(ev, ...a);
168-
}
169-
});
163+
// 新调用覆盖旧 pending:旧调用立即结算(合并语义——只有最后一次返回真实结果)
164+
if (pending) {
165+
pending.resolve(undefined);
170166
}
171167

172-
// 返回一个 resolved promise(批量化场景调用方不依赖即时返回值)
173-
return Promise.resolve(undefined);
168+
return new Promise((resolve, reject) => {
169+
pending = { event, args, resolve, reject };
170+
171+
if (!scheduled) {
172+
scheduled = true;
173+
queueMicrotask(() => {
174+
scheduled = false;
175+
const current = pending;
176+
pending = null;
177+
if (!current) return;
178+
// CL-M3: microtask 中 try/catch 包裹——handler 抛错时 reject 给调用方
179+
// (invoke 收到 rejection 而非 unhandledRejection 状态漂移);
180+
// 最后一次调用可拿到 handler 的真实返回值
181+
Promise.resolve(handler(current.event, ...current.args))
182+
.then(current.resolve)
183+
.catch(current.reject);
184+
});
185+
}
186+
});
174187
};
175188

176189
safeHandle(channel, batchedHandler);

‎client/electron/mcpBridge.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,24 @@ async function handleInit(params: Record<string, unknown>): Promise<Record<strin
126126
return status;
127127
}
128128

129+
// CL-M7: MCP server 子进程最小环境白名单(仅传运行必需的基础变量)
130+
const MINIMAL_ENV_KEYS = [
131+
'PATH', 'SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'HOME', 'USERPROFILE',
132+
'APPDATA', 'LOCALAPPDATA', 'COMSPEC', 'PATHEXT', 'NODE_PATH', 'LANG', 'LC_ALL',
133+
];
134+
135+
/** 构建传给 MCP server 子进程的最小环境(显式白名单,绝不透传宿主全部环境变量) */
136+
function buildMinimalEnv(): Record<string, string> {
137+
const env: Record<string, string> = {};
138+
for (const key of MINIMAL_ENV_KEYS) {
139+
const val = process.env[key];
140+
if (typeof val === 'string' && val) {
141+
env[key] = val;
142+
}
143+
}
144+
return env;
145+
}
146+
129147
async function startServer(config: { name: string; command: string; args: string[] }): Promise<void> {
130148
if (!ClientCtor || !StdioTransportCtor) return;
131149

@@ -143,7 +161,9 @@ async function startServer(config: { name: string; command: string; args: string
143161
const transport = new StdioTransportCtor({
144162
command: config.command,
145163
args: config.args,
146-
env: process.env as Record<string, string>,
164+
// CL-M7: 最小环境白名单——绝不把主进程全部环境变量传给第三方 MCP
165+
// server 子进程(供应链污染可读取宿主凭据);仅传运行必需的基础变量
166+
env: buildMinimalEnv(),
147167
stderr: 'pipe',
148168
});
149169

0 commit comments

Comments
 (0)