Skip to content

Commit edf9964

Browse files
committed
security: harden IPC file access and lock down DB updates
- Fix arbitrary file read/delete via ASR IPC by binding operations to DB speech_recognition_records and validating realpath/ext/size - Add DB helper to look up speech record by audio_file_path for safe authorization checks - Restrict dynamic UPDATE field lists for characters/conversations to allowlisted columns - Default memory-service host to 127.0.0.1 to avoid LAN exposure
1 parent cedaea8 commit edf9964

File tree

5 files changed

+166
-47
lines changed

5 files changed

+166
-47
lines changed

desktop/memory-service/main.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@
88
def run():
99
uvicorn.run(
1010
"app:app",
11-
host=os.environ.get("HOST", "0.0.0.0"),
11+
host=os.environ.get("HOST", "127.0.0.1"),
1212
port=int(os.environ.get("PORT", 8000)),
1313
reload=os.environ.get("RELOAD", "false").lower() == "true",
1414
)
1515

1616

1717
if __name__ == "__main__":
1818
run()
19-

desktop/src/core/modules/ipc-handlers/asr-audio-handlers.js

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,30 @@
11
import { ipcMain } from 'electron';
2+
import path from 'path';
3+
import * as fs from 'node:fs/promises';
4+
5+
const MAX_AUDIO_BYTES = 50 * 1024 * 1024; // 50MB (base64 会更大,限制避免主进程内存被打爆)
6+
const ALLOWED_AUDIO_EXTS = new Set(['.wav', '.webm']);
7+
8+
function normalizeFilePathInput(value) {
9+
if (typeof value !== 'string') return null;
10+
const trimmed = value.trim();
11+
if (!trimmed) return null;
12+
// Null byte guard (avoid odd filesystem behavior)
13+
if (trimmed.includes('\0')) return null;
14+
return trimmed;
15+
}
16+
17+
function resolvePathSafe(value) {
18+
const raw = normalizeFilePathInput(value);
19+
if (!raw) return null;
20+
return path.resolve(raw);
21+
}
22+
23+
function isAllowedAudioPath(filePath) {
24+
if (!filePath) return false;
25+
const ext = path.extname(filePath).toLowerCase();
26+
return ALLOWED_AUDIO_EXTS.has(ext);
27+
}
228

329
/**
430
* 注册 ASR 音频处理相关 IPC 处理器
@@ -217,12 +243,48 @@ export function registerASRAudioHandlers({
217243

218244
ipcMain.handle('asr-get-audio-data-url', async (event, filePath) => {
219245
try {
220-
if (!filePath) return null;
221-
const fs = await import('fs/promises');
222-
const buffer = await fs.readFile(filePath);
246+
const rawPath = normalizeFilePathInput(filePath);
247+
if (!rawPath) return null;
248+
249+
// 关键安全点:仅允许读取「数据库里已有记录」的音频文件路径,避免渲染进程任意读本机文件。
250+
// UI 传入的 filePath 正常情况下就是 record.audio_file_path。
251+
const record =
252+
(typeof db.getSpeechRecordByAudioPath === 'function' ? db.getSpeechRecordByAudioPath(rawPath) : null)
253+
|| (typeof db.getSpeechRecordByAudioPath === 'function' ? db.getSpeechRecordByAudioPath(path.resolve(rawPath)) : null);
254+
255+
if (!record?.audio_file_path) {
256+
console.warn('[ASR] audio data url requested for unknown path (not in DB)');
257+
return null;
258+
}
259+
260+
const resolvedRecordPath = resolvePathSafe(record.audio_file_path);
261+
const resolvedRequestPath = resolvePathSafe(rawPath);
262+
if (!resolvedRecordPath || !resolvedRequestPath) return null;
263+
264+
// 防止 “传入一个不同路径但指向同一文件” 的绕过:尽可能用 realpath 做一致性校验
265+
const realRecordPath = await fs.realpath(resolvedRecordPath).catch(() => resolvedRecordPath);
266+
const realRequestPath = await fs.realpath(resolvedRequestPath).catch(() => resolvedRequestPath);
267+
if (realRecordPath !== realRequestPath) {
268+
console.warn('[ASR] audio data url path mismatch', { realRecordPath, realRequestPath });
269+
return null;
270+
}
271+
272+
if (!isAllowedAudioPath(realRecordPath)) {
273+
console.warn('[ASR] audio data url rejected due to extension', realRecordPath);
274+
return null;
275+
}
276+
277+
const stat = await fs.stat(realRecordPath).catch(() => null);
278+
if (!stat?.isFile?.()) return null;
279+
if (stat.size > MAX_AUDIO_BYTES) {
280+
console.warn('[ASR] audio data url rejected due to size', { size: stat.size, max: MAX_AUDIO_BYTES });
281+
return null;
282+
}
283+
284+
const buffer = await fs.readFile(realRecordPath);
223285
const base64 = buffer.toString('base64');
224286
// Assume WAV for simplicity, or detect from extension
225-
const ext = filePath.split('.').pop().toLowerCase();
287+
const ext = realRecordPath.split('.').pop().toLowerCase();
226288
const mimeType = ext === 'webm' ? 'audio/webm' : 'audio/wav';
227289
return `data:${mimeType};base64,${base64}`;
228290
} catch (error) {
@@ -233,21 +295,43 @@ export function registerASRAudioHandlers({
233295

234296
ipcMain.handle('asr-delete-audio-file', async (event, { recordId, filePath }) => {
235297
try {
236-
const asrManager = getOrCreateASRManager();
298+
if (!recordId) {
299+
return { success: false, error: 'recordId is required' };
300+
}
237301

238-
// Delete physical file
239-
if (filePath) {
240-
const fs = await import('fs/promises');
241-
await fs.unlink(filePath).catch(err => {
242-
console.warn('Physical file already gone or could not be deleted:', err);
243-
});
302+
const record = db.getSpeechRecordById(recordId);
303+
if (!record) {
304+
return { success: false, error: `Speech record not found: ${recordId}` };
244305
}
245306

246-
// Update database
247-
if (recordId) {
307+
const expectedPath = record.audio_file_path || null;
308+
if (!expectedPath) {
309+
// 数据库里已经没有关联文件了:当作成功(幂等)
248310
db.deleteSpeechRecordAudio(recordId);
311+
return { success: true, skipped: true };
249312
}
250313

314+
// 关键安全点:不信任渲染进程传入的 filePath,只允许删除与 recordId 绑定的那一个文件。
315+
if (filePath) {
316+
const expectedResolved = resolvePathSafe(expectedPath);
317+
const providedResolved = resolvePathSafe(filePath);
318+
if (expectedResolved && providedResolved && expectedResolved !== providedResolved) {
319+
return { success: false, error: 'filePath does not match recordId' };
320+
}
321+
}
322+
323+
const resolved = resolvePathSafe(expectedPath);
324+
if (!resolved || !isAllowedAudioPath(resolved)) {
325+
return { success: false, error: 'Invalid audio file path' };
326+
}
327+
328+
const realPath = await fs.realpath(resolved).catch(() => resolved);
329+
await fs.unlink(realPath).catch((err) => {
330+
if (err?.code === 'ENOENT') return;
331+
console.warn('Physical file already gone or could not be deleted:', err);
332+
});
333+
334+
db.deleteSpeechRecordAudio(recordId);
251335
return { success: true };
252336
} catch (error) {
253337
console.error('Error deleting audio file:', error);
@@ -257,4 +341,3 @@ export function registerASRAudioHandlers({
257341

258342
console.log('ASR Audio IPC handlers registered');
259343
}
260-

desktop/src/db/modules/asr.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,19 @@ export default function ASRManager(BaseClass) {
271271
return stmt.get(id);
272272
}
273273

274+
// 通过音频文件路径获取语音识别记录(用于安全地回放/删除录音)
275+
getSpeechRecordByAudioPath(audioFilePath) {
276+
if (!audioFilePath) return null;
277+
const stmt = this.db.prepare(`
278+
SELECT sr.*, asrc.name as source_name
279+
FROM speech_recognition_records sr
280+
LEFT JOIN audio_sources asrc ON sr.source_id = asrc.id
281+
WHERE sr.audio_file_path = ?
282+
LIMIT 1
283+
`);
284+
return stmt.get(audioFilePath);
285+
}
286+
274287
// 更新语音识别记录
275288
updateSpeechRecord(id, updates) {
276289
const fields = [];

desktop/src/db/modules/character.js

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,23 +63,35 @@ export default function CharacterManager(BaseClass) {
6363
};
6464
}
6565

66-
// 更新角色
67-
updateCharacter(id, updates) {
68-
const fields = [];
69-
const values = { id };
66+
// 更新角色
67+
updateCharacter(id, updates) {
68+
const allowedFields = new Set([
69+
'name',
70+
'nickname',
71+
'relationship_label',
72+
'avatar_color',
73+
'affinity',
74+
'notes'
75+
]);
76+
const fields = [];
77+
const values = { id };
7078

71-
for (const [key, value] of Object.entries(updates)) {
72-
if (key !== 'id' && key !== 'tags') {
73-
fields.push(`${key} = @${key}`);
74-
values[key] = value;
75-
}
76-
}
79+
for (const [key, value] of Object.entries(updates)) {
80+
if (allowedFields.has(key)) {
81+
fields.push(`${key} = @${key}`);
82+
values[key] = value;
83+
}
84+
}
7785

78-
fields.push('updated_at = @updated_at');
79-
values.updated_at = Date.now();
86+
if (fields.length === 0) {
87+
return this.getCharacterById(id);
88+
}
8089

81-
const stmt = this.db.prepare(`
82-
UPDATE characters
90+
fields.push('updated_at = @updated_at');
91+
values.updated_at = Date.now();
92+
93+
const stmt = this.db.prepare(`
94+
UPDATE characters
8395
SET ${fields.join(', ')}
8496
WHERE id = @id
8597
`);

desktop/src/db/modules/conversation.js

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -52,23 +52,35 @@ export default function ConversationManager(BaseClass) {
5252
return stmt.get(id);
5353
}
5454

55-
// 更新对话
56-
updateConversation(id, updates) {
57-
const fields = [];
58-
const values = { id };
59-
60-
for (const [key, value] of Object.entries(updates)) {
61-
if (key !== 'id') {
62-
fields.push(`${key} = @${key}`);
63-
values[key] = value;
64-
}
65-
}
66-
67-
fields.push('updated_at = @updated_at');
68-
values.updated_at = Date.now();
69-
70-
const stmt = this.db.prepare(`
71-
UPDATE conversations
55+
// 更新对话
56+
updateConversation(id, updates) {
57+
const allowedFields = new Set([
58+
'character_id',
59+
'title',
60+
'date',
61+
'affinity_change',
62+
'summary',
63+
'tags'
64+
]);
65+
const fields = [];
66+
const values = { id };
67+
68+
for (const [key, value] of Object.entries(updates)) {
69+
if (allowedFields.has(key)) {
70+
fields.push(`${key} = @${key}`);
71+
values[key] = value;
72+
}
73+
}
74+
75+
if (fields.length === 0) {
76+
return this.getConversationById(id);
77+
}
78+
79+
fields.push('updated_at = @updated_at');
80+
values.updated_at = Date.now();
81+
82+
const stmt = this.db.prepare(`
83+
UPDATE conversations
7284
SET ${fields.join(', ')}
7385
WHERE id = @id
7486
`);

0 commit comments

Comments
 (0)