核心修复:
- ssh-service.ts: 新增 resolvePrivateKey(),将私钥文件路径(~/.ssh/id_rsa)解析为内容后传给 ssh2,
解决 'Cannot parse privateKey: Unsupported key format' 错误
- useTerminalConnection.ts + sessionHelper.ts: 根据 authType 过滤字段,password/agent 认证不再传 privateKey
- sessionStore.ts: addSession() 现在先调用 sessionCreate() 持久化到 SQLite,解决恢复标签'无可用连接信息'问题
- database-service.ts: updateSession() 补充 private_key_path 字段持久化
- SessionDialog.tsx: 默认 username='root'、privateKey='~/.ssh/id_rsa'
UI/UX:
- 语义化主题系统(app-theme.ts),强制深色模式,全局无边框设计
- macOS 标题栏支持双击最大化(window:toggleMaximize IPC)
- macOS 菜单栏('OpsXShell' 应用菜单 + Settings Cmd+,)
- WelcomeView 4卡片布局,移除 Settings 按钮
- TabBar/TabItem 背景色统一(bg-dark-800/900),消除白色边框线
架构优化:
- SFTP 生命周期修复(useSFTP.ts useRef + autoConnectAttempted guard)
- 连接池竞态修复,支持快速多标签连接
- IPC 接口规范化,安全错误处理
- Vite 代码分割优化
质量: lint零错误 | tsc零错误 | build成功 | 49 tests pass
271 lines
8.6 KiB
TypeScript
271 lines
8.6 KiB
TypeScript
/**
|
||
* 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('')
|
||
}
|