/** * File IPC Handlers * 处理本地文件操作和远程文件读写 * 安全增强:防止路径遍历攻击 */ import { ipcMain, IpcMainInvokeEvent, app } from 'electron' import fs from 'fs' import path from 'path' // IPC channel names export const FILE_CHANNELS = { // Remote file operations (for zmodem) READ: 'file:read', WRITE: 'file:write', // Local file system operations LOCAL_GET_HOME: 'local:getHome', LOCAL_READ_DIR: 'local:readDir', LOCAL_GET_STATS: 'local:getStats', LOCAL_DELETE: 'local:delete', LOCAL_RENAME: 'local:rename', LOCAL_MKDIR: 'local:mkdir', } as const // 允许的文件操作基础目录白名单 const ALLOWED_BASE_PATHS: string[] = [] /** * 初始化允许的路径白名单 * 防止路径遍历攻击 */ export function initializeAllowedPaths(): void { ALLOWED_BASE_PATHS.push( app.getPath('home'), app.getPath('downloads'), app.getPath('documents'), app.getPath('desktop'), app.getPath('temp'), app.getPath('userData'), ) // 添加平台特定的常用目录 if (process.platform === 'darwin') { ALLOWED_BASE_PATHS.push('/tmp', '/var/tmp') } else if (process.platform === 'linux') { ALLOWED_BASE_PATHS.push('/tmp', '/var/tmp', '/home') } else if (process.platform === 'win32') { ALLOWED_BASE_PATHS.push(process.env.LOCALAPPDATA || '', process.env.TEMP || '') } } /** * 验证文件路径是否在允许的范围内 * 防止路径遍历攻击(如 ../../../etc/passwd) */ function isPathAllowed(filePath: string): boolean { // 规范化路径(解析 .. 和 .) const resolved = path.resolve(filePath) // 检查是否在允许的基目录下 const isUnderAllowedPath = ALLOWED_BASE_PATHS.some((base) => { if (!base) return false const relative = path.relative(base, resolved) return !relative.startsWith('..') && !path.isAbsolute(relative) }) if (isUnderAllowedPath) return true // 如果路径存在,检查其实际位置(处理符号链接) try { const real = fs.realpathSync(resolved) return ALLOWED_BASE_PATHS.some((base) => { if (!base) return false const relative = path.relative(base, real) return !relative.startsWith('..') && !path.isAbsolute(relative) }) } catch { return false } } /** * 安全地规范化路径 */ function sanitizePath(inputPath: string): string { // 移除 null 字节 const cleaned = inputPath.replace(/\0/g, '') // 规范化路径 return path.resolve(cleaned) } export function registerFileIPCHandlers(): void { // 确保白名单已初始化 if (ALLOWED_BASE_PATHS.length === 0) { initializeAllowedPaths() } // Read file (for rz) ipcMain.handle(FILE_CHANNELS.READ, async (_event: IpcMainInvokeEvent, filePath: string) => { try { const safePath = sanitizePath(filePath) if (!isPathAllowed(safePath)) { return { success: false, error: 'Access denied: path traversal detected' } } const data = fs.readFileSync(safePath) return { success: true, data: data.toString('base64') } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { success: false, error: message } } }) // Write file (for sz) ipcMain.handle(FILE_CHANNELS.WRITE, async (_event: IpcMainInvokeEvent, filePath: string, data: string) => { try { const safePath = sanitizePath(filePath) if (!isPathAllowed(safePath)) { return { success: false, error: 'Access denied: path traversal detected' } } const buffer = Buffer.from(data, 'base64') fs.writeFileSync(safePath, buffer) return { success: true } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { success: false, error: message } } }) // Get user home directory ipcMain.handle(FILE_CHANNELS.LOCAL_GET_HOME, async () => { try { const homePath = app.getPath('home') return { success: true, path: homePath } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { success: false, error: message } } }) // Read directory contents ipcMain.handle(FILE_CHANNELS.LOCAL_READ_DIR, async (_event: IpcMainInvokeEvent, dirPath: string) => { try { const safePath = sanitizePath(dirPath) if (!isPathAllowed(safePath)) { return { success: false, error: 'Access denied: path traversal detected' } } const entries = fs.readdirSync(safePath, { withFileTypes: true }) const files = entries.map((entry) => { const fullPath = path.join(dirPath, entry.name) let stats: fs.Stats | null = null try { stats = fs.lstatSync(fullPath) } catch { // Skip files we can't stat } return { name: entry.name, path: fullPath, type: entry.isDirectory() ? 'directory' : entry.isSymbolicLink() ? 'symlink' : 'file', size: stats?.size || 0, modified: stats?.mtime ? Math.floor(stats.mtime.getTime() / 1000) : 0, permissions: formatPermissions(stats?.mode || 0), } }) // Sort: directories first, then by name files.sort((a, b) => { if (a.type !== 'file' && b.type === 'file') return -1 if (a.type === 'file' && b.type !== 'file') return 1 return a.name.localeCompare(b.name) }) return { success: true, files } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { success: false, error: message } } }) // Get file stats ipcMain.handle(FILE_CHANNELS.LOCAL_GET_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => { try { const safePath = sanitizePath(filePath) if (!isPathAllowed(safePath)) { return { success: false, error: 'Access denied: path traversal detected' } } const stats = fs.lstatSync(safePath) return { success: true, stats: { name: path.basename(safePath), path: safePath, type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file', size: stats.size, modified: Math.floor(stats.mtime.getTime() / 1000), permissions: formatPermissions(stats.mode), }, } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { success: false, error: message } } }) // Delete file or directory ipcMain.handle(FILE_CHANNELS.LOCAL_DELETE, async (_event: IpcMainInvokeEvent, filePath: string) => { try { const safePath = sanitizePath(filePath) if (!isPathAllowed(safePath)) { return { success: false, error: 'Access denied: path traversal detected' } } const stats = fs.lstatSync(safePath) if (stats.isDirectory()) { fs.rmSync(safePath, { recursive: true, force: true }) } else { fs.unlinkSync(safePath) } return { success: true } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { success: false, error: message } } }) // Rename file or directory ipcMain.handle(FILE_CHANNELS.LOCAL_RENAME, async (_event: IpcMainInvokeEvent, oldPath: string, newPath: string) => { try { const safeOld = sanitizePath(oldPath) const safeNew = sanitizePath(newPath) if (!isPathAllowed(safeOld) || !isPathAllowed(safeNew)) { return { success: false, error: 'Access denied: path traversal detected' } } fs.renameSync(safeOld, safeNew) return { success: true } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { success: false, error: message } } }) // Create directory ipcMain.handle(FILE_CHANNELS.LOCAL_MKDIR, async (_event: IpcMainInvokeEvent, dirPath: string) => { try { const safePath = sanitizePath(dirPath) if (!isPathAllowed(safePath)) { return { success: false, error: 'Access denied: path traversal detected' } } fs.mkdirSync(safePath, { recursive: true }) return { success: true } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { success: false, error: message } } }) } // Helper function to format Unix permissions function formatPermissions(mode: number): string { const perms: string[] = [] const types = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx'] // Owner perms.push(types[(mode >> 6) & 7]) // Group perms.push(types[(mode >> 3) & 7]) // Other perms.push(types[mode & 7]) return perms.join('') }