Skip to content

Commit 5a2d504

Browse files
committed
fix: 全仓库逻辑合理性检修复 - 三阶段修复27项问题
Phase 1 - 安全与数据完整性: - S1: WS IDOR 修复 (sync-service websocket.go) - S2: WS channel panic 修复 (ws_connection/manager) - S3: GORM FOR UPDATE 兼容 v2 (sync_helpers.go) - S4: IPC 裸 handle 改为 safeHandle (ollama/local-asr/memory) - S5: 路径遍历防护使用 path.relative (storageIpc/videoAnalyze) - S6: 列名注入防护 (sqliteRepository + dbIpcHandlers) - S8: resolve 空数据校验 (sync_resolve.go) - S10: 限流+预算覆盖补齐 (rate_limit/budget) - S12: JWT 竞态与 log.Fatal 修复 (auth.go) Phase 2 - 数据一致性与稳定性: - S7: ClearDataSection 36表全清 + crypto 安全清除 - S11: Logger WriteStream error 处理器 - M2: 流式取消真正中止 HTTP 请求 (gatewayStream + streamHandler) - M8: 迁移完成后重建 FTS5 索引 - M10: 备份顺序修复 (先 checkpoint 再备份) - M11: 番茄钟暂停时长排除 (pausedAt + totalPausedMs) - M12: 打卡日期从 UTC 改为本地时区 - M14: useEffect 不自动打卡 + try/catch - M19: SSE 缓冲上限 1MB Phase 3 - 轻微问题: - L1: 权限 handler 仅放行 media - L2: removeAllListeners 改为 removeListener - L4: Content-Length int 解析异常保护 - L6: template.format 防 params 覆盖 - L7: playBeep 参数交换修复 - L8: 墙钟校准 0s 立即完成 + 预警范围匹配 - L12: decryptField 失败日志 - L14: .env 行内注释剥离
1 parent 8b56d2c commit 5a2d504

29 files changed

Lines changed: 392 additions & 135 deletions

‎client/electron/ai/gatewayStream.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export async function* postJsonStream<TReq>(
1818
body: TReq,
1919
authToken?: string,
2020
timeoutMs: number = 300000,
21+
/** 外部 AbortSignal(如用户取消流式请求),与内部超时 signal 组合 */
22+
externalSignal?: AbortSignal,
2123
): AsyncGenerator<string, void, unknown> {
2224
const base = gatewayUrl();
2325
if (!base) {
@@ -39,6 +41,15 @@ export async function* postJsonStream<TReq>(
3941
const controller = new AbortController();
4042
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
4143

44+
// 组合外部 signal:如果外部 signal 被 abort,也 abort 内部 controller
45+
if (externalSignal) {
46+
if (externalSignal.aborted) {
47+
controller.abort();
48+
} else {
49+
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
50+
}
51+
}
52+
4253
let resp: Response;
4354
try {
4455
resp = await fetch(url, {
@@ -74,6 +85,8 @@ export async function* postJsonStream<TReq>(
7485
const reader = resp.body.getReader();
7586
const decoder = new TextDecoder();
7687
let buffer = '';
88+
/** SSE 缓冲最大字节数,防止恶意服务端无分隔符输出耗尽内存 */
89+
const MAX_BUFFER_SIZE = 1 * 1024 * 1024; // 1MB
7790

7891
try {
7992
while (true) {
@@ -82,6 +95,11 @@ export async function* postJsonStream<TReq>(
8295

8396
buffer += decoder.decode(value, { stream: true });
8497

98+
// 缓冲上限检查:防止恶意服务端无分隔符输出耗尽内存
99+
if (buffer.length > MAX_BUFFER_SIZE) {
100+
throw new Error(`SSE buffer exceeded ${MAX_BUFFER_SIZE} bytes, possible malicious stream`);
101+
}
102+
85103
// 按 \n\n 分割 SSE 事件
86104
const events = buffer.split('\n\n');
87105
buffer = events.pop() || '';

‎client/electron/ai/handlers/videoAnalyzeHandler.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,15 @@ function register(): void {
4646
}
4747

4848
try {
49-
// SEC: 路径安全校验 — 仅允许读取应用数据目录或临时目录(与 main.ts fs:read-file 保持一致)
49+
// SEC: 路径安全校验 — 使用 path.relative 做路径边界检查
5050
const resolvedPath = path.resolve(args.filePath);
5151
const appDataPath = app.getPath('userData');
5252
const tempPath = app.getPath('temp');
53-
if (!resolvedPath.startsWith(appDataPath) && !resolvedPath.startsWith(tempPath)) {
53+
const relToApp = path.relative(appDataPath, resolvedPath);
54+
const relToTemp = path.relative(tempPath, resolvedPath);
55+
const isInApp = !relToApp.startsWith('..') && !path.isAbsolute(relToApp);
56+
const isInTemp = !relToTemp.startsWith('..') && !path.isAbsolute(relToTemp);
57+
if (!isInApp && !isInTemp) {
5458
throw new Error(`[AI] [video-analyze] File path not allowed: ${resolvedPath}`);
5559
}
5660

‎client/electron/ai/local-asr/index.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
* 渲染进程 asrTranscriber.ts 无需任何改动即可切换引擎。
1212
*/
1313

14-
import { ipcMain, BrowserWindow } from 'electron';
14+
import { BrowserWindow } from 'electron';
15+
import { safeHandle } from '../../ipcUtils.js';
1516
import { logger } from '../../logger.js';
1617
import {
1718
getLocalAsrConfig,
@@ -41,7 +42,7 @@ export function registerLocalAsrHandlers(): void {
4142
// ── 转写(核心接口,渲染进程 asrTranscriber.ts 调用) ──
4243
// TODO(P1-3): 本 IPC payload 暂未含可选 hotwords 字段——SenseVoice/Paraformer
4344
// 不支持热词(详见 SherpaAsrService.ts 的 TODO 结论),届时新增字段须保持旧载荷兼容
44-
ipcMain.handle(
45+
safeHandle(
4546
'local_asr_transcribe',
4647
async (_event, args: {
4748
audioBase64: string;
@@ -62,16 +63,16 @@ export function registerLocalAsrHandlers(): void {
6263
);
6364

6465
// ── 配置 ──
65-
ipcMain.handle('local_asr_get_config', () => {
66+
safeHandle('local_asr_get_config', async () => {
6667
return getLocalAsrConfig();
6768
});
6869

69-
ipcMain.handle('local_asr_update_config', async (_event, partial: Partial<LocalAsrConfig>) => {
70+
safeHandle('local_asr_update_config', async (_event, partial: Partial<LocalAsrConfig>) => {
7071
return updateLocalAsrConfig(partial);
7172
});
7273

7374
// ── 可用性检测 ──
74-
ipcMain.handle('local_asr_check_available', async () => {
75+
safeHandle('local_asr_check_available', async () => {
7576
const available = await checkLocalAsrAvailable();
7677
const config = getLocalAsrConfig();
7778
return {
@@ -84,13 +85,13 @@ export function registerLocalAsrHandlers(): void {
8485

8586
// ── 真流式 ASR(Paraformer 在线,课堂 smart 采集实时转录) ──
8687
// 渲染进程据此决定是否走真流式链路(否则回退按段转写)
87-
ipcMain.handle('local_asr_stream_available', () => {
88+
safeHandle('local_asr_stream_available', async () => {
8889
return { available: isStreamingAsrAvailable() };
8990
});
9091

9192
// 启动真流式:创建在线流,后续音频块经 mediaCaptureHandlers 喂入,
9293
// partial/final 结果经 asr_stream_partial / asr_stream_final 事件推回渲染进程
93-
ipcMain.handle(
94+
safeHandle(
9495
'local_asr_stream_start',
9596
async (event, args: { sampleRate?: number }) => {
9697
const win = BrowserWindow.fromWebContents(event.sender);
@@ -101,20 +102,20 @@ export function registerLocalAsrHandlers(): void {
101102
},
102103
);
103104

104-
ipcMain.handle('local_asr_stream_stop', async () => {
105+
safeHandle('local_asr_stream_stop', async () => {
105106
stopStreamingAsr();
106107
return { success: true };
107108
});
108109

109110
// ── 模型管理 ──
110-
ipcMain.handle('local_asr_get_models', () => {
111+
safeHandle('local_asr_get_models', async () => {
111112
return {
112113
models: getModelsStatus(),
113114
download: getDownloadStatus(),
114115
};
115116
});
116117

117-
ipcMain.handle(
118+
safeHandle(
118119
'local_asr_download_model',
119120
async (_event, args: { engine: AsrEngine }) => {
120121
logger.info(`[LocalASR] IPC download request: engine=${args.engine}`);
@@ -123,7 +124,7 @@ export function registerLocalAsrHandlers(): void {
123124
},
124125
);
125126

126-
ipcMain.handle(
127+
safeHandle(
127128
'local_asr_delete_model',
128129
async (_event, args: { engine: AsrEngine }) => {
129130
await deleteModel(args.engine);

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
* @ai-context: Ollama 模块 IPC 注册出口(状态/配置/模型管理通道)。
1010
*/
1111

12-
import { ipcMain, BrowserWindow } from 'electron';
12+
import { BrowserWindow } from 'electron';
13+
import { safeHandle } from '../../ipcUtils.js';
1314
import { logger } from '../../logger.js';
1415
import { getOllamaStatus, pullModel, deleteModel, initOllamaDetection } from './OllamaService.js';
1516
import { loadOllamaConfig, updateOllamaConfig, getOllamaConfig } from './config.js';
@@ -26,7 +27,7 @@ export function registerOllamaHandlers(): void {
2627
logger.info('[Ollama] Registering IPC handlers...');
2728

2829
// ---- 获取状态 ----
29-
ipcMain.handle('ollama:get-status', async (_event, forceRefresh?: boolean) => {
30+
safeHandle('ollama:get-status', async (_event, forceRefresh?: boolean) => {
3031
try {
3132
const status = await getOllamaStatus(forceRefresh ?? false);
3233
const config = getOllamaConfig();
@@ -41,7 +42,7 @@ export function registerOllamaHandlers(): void {
4142
});
4243

4344
// ---- 更新配置 ----
44-
ipcMain.handle('ollama:set-config', async (_event, partial: Record<string, unknown>) => {
45+
safeHandle('ollama:set-config', async (_event, partial: Record<string, unknown>) => {
4546
try {
4647
const updated = await updateOllamaConfig(partial);
4748
logger.info(`[Ollama] Config updated via IPC: enabled=${updated.enabled}`);
@@ -53,7 +54,7 @@ export function registerOllamaHandlers(): void {
5354
});
5455

5556
// ---- 拉取模型 ----
56-
ipcMain.handle('ollama:pull-model', async (event, modelName: string) => {
57+
safeHandle('ollama:pull-model', async (event, modelName: string) => {
5758
if (!modelName || typeof modelName !== 'string') {
5859
throw new Error('Invalid model name');
5960
}
@@ -88,7 +89,7 @@ export function registerOllamaHandlers(): void {
8889
});
8990

9091
// ---- 删除模型 ----
91-
ipcMain.handle('ollama:delete-model', async (_event, modelName: string) => {
92+
safeHandle('ollama:delete-model', async (_event, modelName: string) => {
9293
if (!modelName || typeof modelName !== 'string') {
9394
throw new Error('Invalid model name');
9495
}

‎client/electron/ai/streamHandler.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export function registerStreamHandler(): void {
8181
method,
8282
payload,
8383
authToken,
84+
undefined,
85+
abortController.signal,
8486
);
8587

8688
for await (const chunk of stream) {

‎client/electron/db/dbIpcHandlers.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ export function registerDbIpcHandlers(): void {
135135
safeHandle('db:insert', async (_event, params: { table: string; item: Record<string, unknown> }) => {
136136
const tableName = resolveTable(params.table);
137137
const repo = new SqliteRepository<SqliteRow>(tableName);
138-
const result = await repo.create(params.item as SqliteRow);
138+
// SEC: 过滤非法列名,防止列名注入
139+
const sanitized = repo.filterAllowedColumns(params.item as SqliteRow);
140+
const result = await repo.create(sanitized as Omit<SqliteRow, 'id'> & { id: string });
139141

140142
// 增量索引维护:对含 title/content 字段的表自动更新 FTS5 索引
141143
// 在插入成功后同步执行,确保搜索索引与业务数据保持一致
@@ -156,7 +158,9 @@ export function registerDbIpcHandlers(): void {
156158
safeHandle('db:update', async (_event, params: { table: string; id: string; changes: Record<string, unknown> }) => {
157159
const tableName = resolveTable(params.table);
158160
const repo = new SqliteRepository<SqliteRow>(tableName);
159-
const result = await repo.update(params.id, params.changes as Partial<SqliteRow>);
161+
// SEC: 过滤非法列名,防止列名注入
162+
const sanitized = repo.filterAllowedColumns(params.changes as Partial<SqliteRow>);
163+
const result = await repo.update(params.id, sanitized);
160164

161165
// 增量索引维护:更新操作需要重新读取完整行数据(changes 可能只包含部分字段),
162166
// 然后更新 FTS5 索引中的对应文档

‎client/electron/db/migration.ts‎

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

1010
import type Database from 'better-sqlite3';
1111
import { getConnection } from './sqliteService.js';
12+
import { rebuildIndex, collectIndexableData } from './fts5Search.js';
1213
import { isImportableTable } from './importWhitelist.js';
1314
import { logger } from '../logger.js';
1415

@@ -195,6 +196,14 @@ export function registerMigrationHandlers(
195196
safeHandle('migration:complete', async () => {
196197
try {
197198
const result = completeMigration(db);
199+
// 迁移完成后重建 FTS5 全文索引(迁移数据绕过业务写入路径,未触发增量索引维护)
200+
try {
201+
const indexData = collectIndexableData(db);
202+
rebuildIndex(indexData);
203+
logger.info(`[Migration] FTS5 index rebuilt: ${indexData.reduce((sum, t) => sum + t.rows.length, 0)} documents`);
204+
} catch (ftsErr) {
205+
logger.warn(`[Migration] FTS5 index rebuild failed (non-fatal): ${ftsErr instanceof Error ? ftsErr.message : String(ftsErr)}`);
206+
}
198207
return { success: result.ok, integrity: result.integrity };
199208
} catch (err) {
200209
const msg = err instanceof Error ? err.message : String(err);

‎client/electron/db/sqliteRepository.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,24 @@ function entityToRow(entity: Record<string, unknown>, meta: TableMeta): Record<s
103103

104104
/** 用双引号包裹列名,防止 SQLite 保留字冲突 */
105105
function q(col: string): string {
106-
return `"${col}"`;
106+
// SEC: 转义列名中的双引号,防止列名注入(如 `a" --` 闭合引号后注释掉剩余 SQL)
107+
return `"${col.replace(/"/g, '""')}"`;
108+
}
109+
110+
/** 缓存每张表的列名白名单 */
111+
const columnCache = new Map<string, Set<string>>();
112+
113+
/**
114+
* 获取表的合法列名集合(从 PRAGMA table_info 查询并缓存)
115+
*/
116+
function getTableColumns(tableName: string): Set<string> {
117+
const cached = columnCache.get(tableName);
118+
if (cached) return cached;
119+
const db = getConnection();
120+
const rows = db.prepare(`PRAGMA table_info("${tableName.replace(/"/g, '""')}")`).all() as Array<{ name: string }>;
121+
const cols = new Set(rows.map((r) => r.name));
122+
columnCache.set(tableName, cols);
123+
return cols;
107124
}
108125

109126
// ================================================================
@@ -123,6 +140,21 @@ export default class SqliteRepository<T extends { id: string }> implements IRepo
123140
return getConnection();
124141
}
125142

143+
/**
144+
* 过滤非法列名,仅保留表中真实存在的列
145+
* SEC: 防止列名注入(防御纵深:配合 q() 的双引号转义)
146+
*/
147+
filterAllowedColumns(data: Record<string, unknown>): Record<string, unknown> {
148+
const allowed = getTableColumns(this.tableName);
149+
const result: Record<string, unknown> = {};
150+
for (const [key, value] of Object.entries(data)) {
151+
if (allowed.has(key)) {
152+
result[key] = value;
153+
}
154+
}
155+
return result;
156+
}
157+
126158
async getAll(): Promise<T[]> {
127159
const rows = this.db.prepare(`SELECT * FROM ${this.tableName}`).all() as Record<string, unknown>[];
128160
return rows.map((r) => rowToEntity<T>(r, this.meta));

‎client/electron/envLoader.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,26 @@ function loadEnvFile(filePath: string, overrideKeys: Set<string>): void {
2727
if (eqIdx === -1) continue;
2828
const key = trimmed.slice(0, eqIdx).trim();
2929
let value = trimmed.slice(eqIdx + 1).trim();
30+
// 移除行内注释(# 号,但需跳过引号内的 #)
31+
if (value.includes('#')) {
32+
// 简单处理:不在引号内的 # 视为注释起始
33+
let inQuote = false;
34+
let quoteChar = '';
35+
let commentIdx = -1;
36+
for (let i = 0; i < value.length; i++) {
37+
const ch = value[i];
38+
if ((ch === '"' || ch === "'") && (!inQuote || ch === quoteChar)) {
39+
inQuote = !inQuote;
40+
quoteChar = inQuote ? ch : '';
41+
} else if (ch === '#' && !inQuote) {
42+
commentIdx = i;
43+
break;
44+
}
45+
}
46+
if (commentIdx >= 0) {
47+
value = value.slice(0, commentIdx).trimEnd();
48+
}
49+
}
3050
// 移除首尾引号
3151
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
3252
value = value.slice(1, -1);

‎client/electron/logger.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ class Logger {
2929
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
3030
const logFile = path.join(logsDir, `app-${timestamp}.log`);
3131
this.stream = fs.createWriteStream(logFile, { flags: 'a' });
32+
this.stream.on('error', (err) => {
33+
console.error('[Logger] Write stream error, falling back to console:', err);
34+
this.stream = null;
35+
});
3236
this.stream.write(`# EntropyDecrease log started at ${new Date().toISOString()}\n`);
3337
} catch (err) {
3438
console.error('[Logger] Failed to initialize file logger:', err);

0 commit comments

Comments
 (0)