Skip to content

Commit 226259a

Browse files
committed
fix: 全仓库第二轮审计修复 - 四区域50项中等问题+5项严重
AI 网关 (15项): - GeminiProvider 同步 SDK 改 asyncio.to_thread 非阻塞 - auth.py httpx.get 改异步 + JWKS 失败转 AuthenticationError - CostTracker.record 接入 fallback 链(预算控制生效) - multimodal_video Content-Length 异常保护 - SSE 错误信息脱敏 + 流式总体 300s 超时上限 - 限流改原子 INCR 消除 TOCTOU - fallback 预算系数 1.5→3.0 - prompt_guard 跳过 base64/超长字段 - 流式首 token 探测仅超时重试 + 确定性错误直接切换 - KeyPool 多 Key 轮询接入请求路径 - 熔断器 HALF_OPEN 用 Condition 协调 + 指标接入 - 流式链 _FEATURE_CONTEXT 成功路径不清空 Electron (9项): - db:batch 列名过滤 + 走 SqliteRepository 序列化 - Ollama baseUrl SSRF 防护(仅 localhost) - MCP Bridge 请求 30s 超时 - import:parse-pdf 路径边界校验 - 停止录制等渲染确认再关流(修丢尾帧) - 日志 30 天轮转 + 孤儿录制文件清理 - FTS 索引重建改异步 + 增量队列 前端 (21项): - 番茄钟墙钟分支补 phase/completedCount/成就/珊瑚 - totalPausedMs 跨轮重置 + exitImmersive 补 pausedAt - 入籍重试跳过已安放概念(幂等) - 闪卡复习走 createWithLog 同步链路 - OfflineQueue 事务内原子版本号 - Profile blob URL revoke + 加密备份错误精准提示 - useAIBalance TimeoutError 分支 - SyncEngine 独立 paused 标志 - 导入备份重置 CRDT 引擎 - adaptiveEngine 清理 timer + AbortSignal - conflictCount 快照重置 + 初始在线状态修正 - NetworkManager 延迟通知 + 引用计数心跳 - ClearDataSection 补连字符键 + 重置模式 - relearn 重置 cardStartTime + 进度语义修正 同步服务 (13项): - Push 版本检查移入事务+行锁(TOCTOU) - 增量查询 LIMIT 1000 + hasMore - Redis TTL + SetDeviceOffline - DB 连接池参数 + 优雅关闭 + 请求体 1MB 上限 - Operation 幂等唯一索引 + skipped - 批量 push 单事务 + DB 错误日志化 - WS 查库异步化 + 连接数上限 + deviceID 白名单 - 广播携带 ServerSeqNo 统一游标语义
1 parent 490d5a5 commit 226259a

49 files changed

Lines changed: 1173 additions & 385 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎client/electron/ai/ollama/config.ts‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,43 @@ const DEFAULT_CONFIG: OllamaConfig = {
5656
registryMirror: '',
5757
};
5858

59+
// ================================================================
60+
// baseUrl 安全校验(SSRF 防护)
61+
// ================================================================
62+
63+
/**
64+
* 校验 Ollama baseUrl — 仅允许本机回环地址,拒绝其他所有主机名/IP。
65+
* 防止渲染进程把 baseUrl 指向内网或公网地址(SSRF 攻击面)。
66+
* 允许:http://localhost:* / http://127.0.0.1:* / http://[::1]:*
67+
* (含 WHATWG URL 规范化的 IPv4-mapped 形式 ::ffff:127.0.0.1)
68+
* @returns 违规原因;null 表示通过 / Return block reason or null
69+
*/
70+
export function validateOllamaBaseUrl(rawUrl: string): string | null {
71+
let parsed: URL;
72+
try {
73+
parsed = new URL(rawUrl);
74+
} catch {
75+
return 'URL 格式无效';
76+
}
77+
if (parsed.protocol !== 'http:') {
78+
return '仅支持 http:// 协议';
79+
}
80+
// hostname 已去除 [],IPv6 形如 ::1,IPv4-mapped 形如 ::ffff:127.0.0.1
81+
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
82+
const isLoopback =
83+
host === 'localhost' ||
84+
host === '127.0.0.1' ||
85+
host === '::1' ||
86+
host === '0:0:0:0:0:0:0:1' ||
87+
// IPv4-mapped IPv6(::ffff:127.0.0.1)规范化后必须一并放行(仍指向本机)
88+
host === '::ffff:127.0.0.1' ||
89+
host === '::ffff:7f00:1';
90+
if (!isLoopback) {
91+
return '仅允许连接本机 Ollama 服务(localhost / 127.0.0.1 / [::1])';
92+
}
93+
return null;
94+
}
95+
5996
// ================================================================
6097
// 运行时状态
6198
// ================================================================
@@ -91,7 +128,9 @@ export async function loadOllamaConfig(): Promise<void> {
91128
const parsed = JSON.parse(raw);
92129
_config = {
93130
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_CONFIG.enabled,
131+
// SEC: 持久化文件中的 baseUrl 同样校验——防止历史/被篡改配置绕过 SSRF 防护
94132
baseUrl: typeof parsed.baseUrl === 'string' && parsed.baseUrl.trim()
133+
&& validateOllamaBaseUrl(parsed.baseUrl.trim()) === null
95134
? parsed.baseUrl.trim().replace(/\/$/, '')
96135
: DEFAULT_CONFIG.baseUrl,
97136
models: {
@@ -115,11 +154,19 @@ export async function loadOllamaConfig(): Promise<void> {
115154
*/
116155
export async function updateOllamaConfig(partial: Partial<OllamaConfig>): Promise<OllamaConfig> {
117156
const current = getOllamaConfig();
157+
// SEC: baseUrl SSRF 校验——仅允许本机回环地址,违规直接拒绝更新
158+
let nextBaseUrl = current.baseUrl;
159+
if (partial.baseUrl) {
160+
const trimmed = partial.baseUrl.trim().replace(/\/$/, '');
161+
const blockReason = validateOllamaBaseUrl(trimmed);
162+
if (blockReason) {
163+
throw new Error(`[Ollama] baseUrl 校验失败:${blockReason}`);
164+
}
165+
nextBaseUrl = trimmed;
166+
}
118167
const updated: OllamaConfig = {
119168
enabled: partial.enabled ?? current.enabled,
120-
baseUrl: partial.baseUrl
121-
? partial.baseUrl.trim().replace(/\/$/, '')
122-
: current.baseUrl,
169+
baseUrl: nextBaseUrl,
123170
models: {
124171
text: partial.models?.text ?? current.models.text,
125172
vision: partial.models?.vision ?? current.models.vision,

‎client/electron/db/dbIpcHandlers.ts‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -251,28 +251,38 @@ export function registerDbIpcHandlers(): void {
251251
});
252252

253253
/** db:batch — 批量操作:事务执行 */
254+
// SEC(M13): insert/update 分支先经 repo.filterAllowedColumns() 过滤非法列名,
255+
// 再拼入 SQL(repo 内部统一使用 q() 包裹列名,防列名注入)
256+
// SEC(M17): 直接调用 repo.create()/repo.update() 替代手写 SQL,
257+
// JSON 字段会经 entityToRow 序列化、boolean 字段正确映射为 INTEGER 0/1
254258
safeHandle('db:batch', async (_event, params: { operations: Array<{ type: string; table: string; [key: string]: unknown }> }) => {
255259
const dbConn = getConnection();
256260

257261
const txn = dbConn.transaction(() => {
258262
for (const op of params.operations) {
259263
const tableName = resolveTable(op.table as string);
260264
switch (op.type) {
261-
case 'insert': {
265+
// 'create' 为渲染进程 IpcStorageAdapter.bulkCreate 使用的类型名(兼容映射)
266+
case 'insert':
267+
case 'create': {
268+
const repo = new SqliteRepository<SqliteRow>(tableName);
262269
const item = op.item as Record<string, unknown>;
263-
const cols = Object.keys(item);
264-
const placeholders = cols.map(() => '?').join(', ');
265-
const sql = `INSERT INTO "${tableName}" (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`;
266-
dbConn.prepare(sql).run(...Object.values(item));
270+
const sanitized = repo.filterAllowedColumns(item);
271+
if (Object.keys(sanitized).length === 0) {
272+
throw new Error(`[DB] Batch insert has no allowed columns for table "${tableName}"`);
273+
}
274+
if (!sanitized.id) {
275+
throw new Error(`[DB] Batch insert requires an "id" field for table "${tableName}"`);
276+
}
277+
repo.create(sanitized as Omit<SqliteRow, 'id'> & { id: string });
267278
break;
268279
}
269280
case 'update': {
281+
const repo = new SqliteRepository<SqliteRow>(tableName);
270282
const changes = op.changes as Record<string, unknown>;
271-
const entries = Object.entries(changes);
272-
if (entries.length === 0) break;
273-
const setClauses = entries.map(([col]) => `"${col}" = ?`).join(', ');
274-
const sql = `UPDATE "${tableName}" SET ${setClauses} WHERE id = ?`;
275-
dbConn.prepare(sql).run(...entries.map(([, v]) => v), op.id as string);
283+
const sanitized = repo.filterAllowedColumns(changes);
284+
if (Object.keys(sanitized).length === 0) break;
285+
repo.update(op.id as string, sanitized);
276286
break;
277287
}
278288
case 'delete':

‎client/electron/db/fts5Search.ts‎

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,50 @@ CREATE VIRTUAL TABLE IF NOT EXISTS fts_content USING fts5(
4545
tokenize='unicode61 remove_diacritics 2'
4646
);`;
4747

48+
// ================================================================
49+
// 就绪状态与增量写入队列(M21)
50+
// ================================================================
51+
52+
/**
53+
* FTS 索引是否已就绪。启动时全量重建在 setTimeout 中异步执行,
54+
* 完成前为 false——期间增量写入(indexDocument/removeDocument)
55+
* 进入 pendingFtsOps 队列,待 setFtsIndexReady(true) 时统一 flush,
56+
* 保证不丢索引也不阻塞业务写入。
57+
*/
58+
let ftsIndexReady = false;
59+
60+
/** 重建完成前积压的增量索引操作 */
61+
const pendingFtsOps: Array<() => void> = [];
62+
63+
/** 设置索引就绪状态;置 true 时 flush 积压队列 */
64+
export function setFtsIndexReady(ready: boolean): void {
65+
ftsIndexReady = ready;
66+
if (!ready) return;
67+
const ops = pendingFtsOps.splice(0);
68+
for (const op of ops) {
69+
try {
70+
op();
71+
} catch (err) {
72+
// 单个积压操作失败不阻塞其余 flush
73+
logger.warn(`[FTS5] Flush queued index op failed: ${err instanceof Error ? err.message : String(err)}`);
74+
}
75+
}
76+
}
77+
78+
/** 索引是否已就绪(重建完成) */
79+
export function isFtsIndexReady(): boolean {
80+
return ftsIndexReady;
81+
}
82+
83+
/** 就绪则立即执行,否则入队等待重建完成 */
84+
function whenFtsReady(op: () => void): void {
85+
if (ftsIndexReady) {
86+
op();
87+
} else {
88+
pendingFtsOps.push(op);
89+
}
90+
}
91+
4892
// ================================================================
4993
// 公共 API
5094
// ================================================================
@@ -55,19 +99,23 @@ export function initializeFTS(db: Database.Database): void {
5599
logger.info('[FTS5] Full-text search virtual table initialized');
56100
}
57101

58-
/** 索引或更新一条文档(先删后插保证幂等) */
102+
/** 索引或更新一条文档(先删后插保证幂等;M21: 重建完成前入队延迟执行) */
59103
export function indexDocument(table: string, id: string, title: string, content: string): void {
60-
const db = getConnection();
61-
db.prepare(`DELETE FROM fts_content WHERE id = ? AND table_name = ?`).run(id, table);
62-
db.prepare(`INSERT INTO fts_content (id, table_name, title, content) VALUES (?, ?, ?, ?)`)
63-
.run(id, table, title, content);
104+
whenFtsReady(() => {
105+
const db = getConnection();
106+
db.prepare(`DELETE FROM fts_content WHERE id = ? AND table_name = ?`).run(id, table);
107+
db.prepare(`INSERT INTO fts_content (id, table_name, title, content) VALUES (?, ?, ?, ?)`)
108+
.run(id, table, title, content);
109+
});
64110
}
65111

66-
/** 从全文索引中删除一条文档 */
112+
/** 从全文索引中删除一条文档(M21: 重建完成前入队延迟执行) */
67113
export function removeDocument(table: string, id: string): void {
68-
getConnection()
69-
.prepare(`DELETE FROM fts_content WHERE id = ? AND table_name = ?`)
70-
.run(id, table);
114+
whenFtsReady(() => {
115+
getConnection()
116+
.prepare(`DELETE FROM fts_content WHERE id = ? AND table_name = ?`)
117+
.run(id, table);
118+
});
71119
}
72120

73121
/**

‎client/electron/importHandlers.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* URLs are restricted to HTTP(S) with SSRF guard + 10s timeout. All
1212
* failures return Result objects so the UI can fall back to manual paste.
1313
*/
14-
import { dialog } from 'electron';
14+
import { app, dialog } from 'electron';
1515
import * as path from 'path';
1616
import { readFile, stat } from 'fs/promises';
1717
import { randomUUID } from 'crypto';
@@ -203,6 +203,18 @@ export function registerImportHandlers(): void {
203203
filePath = picked.filePaths[0];
204204
} else {
205205
filePath = payload.filePath;
206+
// SEC(M16): 路径边界检查——仅允许 userData 或 temp 目录下的文件
207+
// (path.relative 方式参考 storageIpcHandlers.ts,防兄弟目录/穿越绕过)
208+
const appDataPath = app.getPath('userData');
209+
const tempPath = app.getPath('temp');
210+
const resolvedPath = path.resolve(filePath);
211+
const relToApp = path.relative(appDataPath, resolvedPath);
212+
const relToTemp = path.relative(tempPath, resolvedPath);
213+
const isInApp = !relToApp.startsWith('..') && !path.isAbsolute(relToApp);
214+
const isInTemp = !relToTemp.startsWith('..') && !path.isAbsolute(relToTemp);
215+
if (!isInApp && !isInTemp) {
216+
return { success: false, error: '不允许读取该路径的文件' };
217+
}
206218
}
207219

208220
try {

‎client/electron/logger.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,36 @@ class Logger {
1717
private stream: fs.WriteStream | null = null;
1818
private initialized = false;
1919

20+
/**
21+
* 清理超过 maxAgeMs 的过期日志文件(M19)
22+
* 文件名格式 app-<ISO 时间戳>.log(如 app-2026-08-04T12-34-56-789Z.log),
23+
* 解析其中的时间戳并删除早于保留期的文件;无法解析的文件跳过不删
24+
*/
25+
private cleanupOldLogs(logsDir: string, maxAgeMs: number): void {
26+
let removed = 0;
27+
try {
28+
const cutoff = Date.now() - maxAgeMs;
29+
for (const file of fs.readdirSync(logsDir)) {
30+
const m = file.match(/^app-(.+\.log)$/);
31+
if (!m) continue;
32+
// 还原 ISO 时间戳:2026-08-04T12-34-56-789Z → 2026-08-04T12:34:56.789Z
33+
// (仅替换时间段的 -,日期部分的 - 保留,故从尾部锚定匹配)
34+
const iso = m[1].replace(/(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, '$1:$2:$3.$4Z');
35+
const fileTime = new Date(iso).getTime();
36+
if (Number.isNaN(fileTime)) continue; // 无法解析的文件名跳过
37+
if (fileTime < cutoff) {
38+
fs.unlinkSync(path.join(logsDir, file));
39+
removed++;
40+
}
41+
}
42+
} catch (err) {
43+
console.error('[Logger] Failed to cleanup old log files:', err);
44+
}
45+
if (removed > 0) {
46+
console.log(`[Logger] Cleaned up ${removed} log file(s) older than ${maxAgeMs / 86400000} days`);
47+
}
48+
}
49+
2050
/** 初始化日志目录和文件流(异步,需在 app ready 后调用) */
2151
async initLogger(): Promise<void> {
2252
if (this.initialized) return;
@@ -26,6 +56,9 @@ class Logger {
2656
const logsDir = path.join(app.getPath('userData'), 'logs');
2757
await mkdir(logsDir, { recursive: true });
2858

59+
// M19: 日志轮转——启动时清理超过 30 天的历史日志文件
60+
this.cleanupOldLogs(logsDir, 30 * 24 * 60 * 60 * 1000);
61+
2962
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
3063
const logFile = path.join(logsDir, `app-${timestamp}.log`);
3164
this.stream = fs.createWriteStream(logFile, { flags: 'a' });

‎client/electron/main.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { mcpManager } from './mcpManager.js';
3030
import { registerMemoryServerConsentHandlers } from './memoryServerConsent.js';
3131
import { initialize, close as closeDb } from './db/sqliteService.js';
3232
import { initializeSchema } from './db/schema.js';
33-
import { initializeFTS, rebuildIndex, collectIndexableData } from './db/fts5Search.js';
33+
import { initializeFTS, rebuildIndex, collectIndexableData, setFtsIndexReady } from './db/fts5Search.js';
3434
import { resolveDbPath } from './db/storageConfig.js';
3535
import { registerMigrationHandlers } from './db/migration.js';
3636
import { registerImportHandlers } from './importHandlers.js';
@@ -165,9 +165,21 @@ if (!gotTheLock) {
165165
initializeFTS(sqliteDb);
166166
// 启动时构建存量索引——从 notes/flashcards/feynman_notes 等表读取已有数据,
167167
// 填充 FTS5 虚拟表,使搜索功能立即可用(而非等待用户触发增量更新)
168-
const indexData = collectIndexableData(sqliteDb);
169-
rebuildIndex(indexData);
170-
logger.info(`[DB] SQLite initialized and schema ready (FTS5 enabled, ${indexData.reduce((sum, t) => sum + t.rows.length, 0)} documents indexed)`);
168+
// M21: 全量重建放入 setTimeout 异步执行,不阻塞启动关键路径(窗口创建等);
169+
// 期间增量索引写入(indexDocument/removeDocument)自动进入队列,
170+
// 重建完成后由 setFtsIndexReady(true) 统一 flush,不丢索引
171+
setTimeout(() => {
172+
try {
173+
const indexData = collectIndexableData(sqliteDb);
174+
rebuildIndex(indexData);
175+
logger.info(`[DB] FTS5 index rebuilt: ${indexData.reduce((sum, t) => sum + t.rows.length, 0)} documents indexed`);
176+
} catch (err) {
177+
logger.warn(`[FTS5] Startup index rebuild failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
178+
} finally {
179+
setFtsIndexReady(true);
180+
}
181+
}, 0);
182+
logger.info('[DB] SQLite initialized and schema ready (FTS5 enabled)');
171183

172184
// v1.0.0: 注册数据迁移 IPC handlers(IndexedDB → SQLite)
173185
registerMigrationHandlers(safeHandle);

‎client/electron/mcpManager.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ import {
2828
type BridgeRequest, type BridgeResponse, type McpToolInfo, type ServerName,
2929
} from './mcpEnv.js';
3030

31+
// ================================================================
32+
// 常量
33+
// ================================================================
34+
35+
/** 单次请求超时(ms)——bridge 无响应时拒绝并清理 pending,防请求悬挂 */
36+
const REQUEST_TIMEOUT_MS = 30_000;
37+
3138
// ================================================================
3239
// 管理器
3340
// ================================================================
@@ -190,6 +197,8 @@ class McpManager {
190197

191198
/**
192199
* 向 bridge 发送请求并等待响应
200+
* SEC(M15): 30s 超时兜底——bridge 挂起/失联时拒绝请求并清理 pending 条目,
201+
* 避免调用方(渲染进程 await)无限悬挂
193202
*/
194203
private sendRequest(method: BridgeRequest['method'], params: Record<string, unknown>): Promise<unknown> {
195204
return new Promise((resolve, reject) => {
@@ -201,11 +210,29 @@ class McpManager {
201210
const id = `req_${++this.requestCounter}`;
202211
const request: BridgeRequest = { id, method, params };
203212

204-
this.pending.set(id, { resolve, reject });
213+
// 超时定时器:resolve/reject 任意路径都会通过包装函数清理
214+
const timeoutId = setTimeout(() => {
215+
if (this.pending.has(id)) {
216+
this.pending.delete(id);
217+
reject(new Error(`[MCP] Request ${id} (${method}) timed out after ${REQUEST_TIMEOUT_MS}ms`));
218+
}
219+
}, REQUEST_TIMEOUT_MS);
220+
221+
this.pending.set(id, {
222+
resolve: (value: unknown) => {
223+
clearTimeout(timeoutId);
224+
resolve(value);
225+
},
226+
reject: (reason: Error) => {
227+
clearTimeout(timeoutId);
228+
reject(reason);
229+
},
230+
});
205231

206232
this.bridge.send(request, (err) => {
207233
if (err) {
208234
this.pending.delete(id);
235+
clearTimeout(timeoutId);
209236
reject(new Error(`Failed to send request: ${err.message}`));
210237
}
211238
});

‎client/electron/mediaCaptureHandlers.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { AudioCapture, listAudioSources } from './audioCapture.js';
1212
import { listMicrophoneDevices } from './audio/microphoneProvider.js';
1313
import type { AudioCaptureOptions, AudioChunk } from './audioCapture.js';
1414
import type { AudioSourcePreference } from '../src/lib/capture/audioSourceStrategy.js';
15-
import { VideoRecorder } from './videoRecorder.js';
15+
import { VideoRecorder, cleanupOrphanRecordings } from './videoRecorder.js';
1616
import type { VideoRecordOptions } from './videoRecorder.js';
1717
import { safeHandle, getMainWindowId } from './ipcUtils.js';
1818
import { logger } from './logger.js';
@@ -45,6 +45,10 @@ function verifySender(senderId: number, channel: string): boolean {
4545
* 注册音频捕获与视频录制相关的 IPC handler
4646
*/
4747
export function registerMediaCaptureHandlers(): void {
48+
// ---- 启动清理(M20)----
49+
// 崩溃/强杀残留的孤儿录制文件在注册时回收(仅清 24h 前的),防临时目录堆积
50+
cleanupOrphanRecordings();
51+
4852
// ---- 系统音频捕获 ----
4953

5054
safeHandle('audio_list_sources', async () => {

0 commit comments

Comments
 (0)