11import { 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-
0 commit comments