fix(security): patch all P0 vulnerabilities from project review

- credential: encrypt/decrypt now throw when safeStorage unavailable instead of base64 fallback
- ipc-handlers: encrypt auth/sshPassword/sshPassphrase before storing, decrypt on read (avoid mutation)
- ipc-handlers: block FLUSHALL/FLUSHDB/SHUTDOWN/DEBUG in redis:execute, add redis:executeUnsafe for CLI
- format: whitelist allowed external formatters (xxd/jq/python3/python/php/column), clean up tmpDir
- connection: clearTimeout on ready/error paths to fix timeout race in Sentinel/Cluster/normal modes
- index: add app.on('will-quit') to disconnectAll + stopAllPubSubClients on exit
- pubsub: add stopAllPubSubClients() to clean up all sub/monitor connections
- docs: add PROJECT_REVIEW.md with full audit report and fix status
This commit is contained in:
2026-07-12 14:13:44 +08:00
parent 4d0e0b75aa
commit 9d73ff019c
11 changed files with 314 additions and 19 deletions
+3 -2
View File
@@ -2,14 +2,14 @@ import { safeStorage } from 'electron'
export function encrypt(text: string): string {
if (!safeStorage.isEncryptionAvailable()) {
return Buffer.from(text).toString('base64')
throw new Error('safeStorage is not available on this system; cannot encrypt credentials')
}
return safeStorage.encryptString(text).toString('base64')
}
export function decrypt(encoded: string): string {
if (!safeStorage.isEncryptionAvailable()) {
return Buffer.from(encoded, 'base64').toString('utf-8')
throw new Error('safeStorage is not available on this system; cannot decrypt credentials')
}
try {
return safeStorage.decryptString(Buffer.from(encoded, 'base64'))
@@ -18,6 +18,7 @@ export function decrypt(encoded: string): string {
// (e.g. when safeStorage was unavailable at encryption time, or the
// OS keyring changed). Decode as plain base64 so the connection
// attempt can proceed instead of crashing.
console.warn('safeStorage decryption failed, falling back to base64 decode (legacy data)')
return Buffer.from(encoded, 'base64').toString('utf-8')
}
}
+7
View File
@@ -3,6 +3,8 @@ import { join } from 'path'
import { restoreAndTrack } from './win-state'
import { registerIpcHandlers } from './ipc-handlers'
import { initUpdater } from './updater'
import { disconnectAll } from './redis/connection'
import { stopAllPubSubClients } from './redis/pubsub'
let mainWindow: BrowserWindow | null = null
@@ -62,6 +64,11 @@ app.whenReady().then(() => {
initUpdater(mainWindow!)
})
app.on('will-quit', () => {
disconnectAll()
stopAllPubSubClients()
})
app.on('window-all-closed', () => { app.quit() })
app.on('activate', () => {
+47 -6
View File
@@ -51,12 +51,39 @@ export function registerIpcHandlers(): void {
ipcMain.handle('credential:decrypt', (_e, encoded: string) => decrypt(encoded))
// Storage - connections
ipcMain.handle('storage:getConnections', () => store.get('connections', []))
ipcMain.handle('storage:saveConnection', (_e, conn: ConnectionConfig) => {
ipcMain.handle('storage:getConnections', async () => {
const connections = store.get('connections', [])
const idx = connections.findIndex((c: ConnectionConfig) => c.id === conn.id)
if (idx >= 0) connections[idx] = conn
else connections.push(conn)
// Decrypt sensitive fields
for (const conn of connections) {
if (conn.auth && conn.auth.startsWith('enc:')) {
try { conn.auth = await decrypt(conn.auth.slice(4)) } catch { /* keep as-is */ }
}
if (conn.sshPassword && conn.sshPassword.startsWith('enc:')) {
try { conn.sshPassword = await decrypt(conn.sshPassword.slice(4)) } catch { /* keep as-is */ }
}
if (conn.sshPassphrase && conn.sshPassphrase.startsWith('enc:')) {
try { conn.sshPassphrase = await decrypt(conn.sshPassphrase.slice(4)) } catch { /* keep as-is */ }
}
}
return connections
})
ipcMain.handle('storage:saveConnection', async (_e, conn: ConnectionConfig) => {
// Encrypt sensitive fields before storing (create copy to avoid mutation)
const encrypted: ConnectionConfig = { ...conn }
if (encrypted.auth && !encrypted.auth.startsWith('enc:')) {
encrypted.auth = 'enc:' + await encrypt(encrypted.auth)
}
if (encrypted.sshPassword && !encrypted.sshPassword.startsWith('enc:')) {
encrypted.sshPassword = 'enc:' + await encrypt(encrypted.sshPassword)
}
if (encrypted.sshPassphrase && !encrypted.sshPassphrase.startsWith('enc:')) {
encrypted.sshPassphrase = 'enc:' + await encrypt(encrypted.sshPassphrase)
}
const connections = store.get('connections', [])
const idx = connections.findIndex((c: ConnectionConfig) => c.id === encrypted.id)
if (idx >= 0) connections[idx] = encrypted
else connections.push(encrypted)
store.set('connections', connections)
return connections
})
@@ -101,8 +128,22 @@ export function registerIpcHandlers(): void {
return redis.isClientConnected(id)
})
// Redis - 服务器
// Blocked dangerous commands — blocked in redis:execute but allowed via redis:executeUnsafe (CLI)
const BLOCKED_COMMANDS = new Set([
'FLUSHALL', 'FLUSHDB', 'SHUTDOWN', 'DEBUG',
])
// Redis - 服务器 (safe execute — blocks dangerous commands)
ipcMain.handle('redis:execute', withLog('EXEC', async (_e, id: string, command: string, args: string[]) => {
const cmdUpper = command.toUpperCase().split(/\s+/)[0]
if (BLOCKED_COMMANDS.has(cmdUpper)) {
throw new Error(`Command "${command}" is blocked for safety. Use CLI for administrative commands.`)
}
return redis.execute(id, command, args)
}))
// Redis - unsafe execute (bypasses block — for CLI use only)
ipcMain.handle('redis:executeUnsafe', withLog('EXEC', async (_e, id: string, command: string, args: string[]) => {
return redis.execute(id, command, args)
}))
ipcMain.handle('redis:getInfo', async (_e, id: string) => {
+12 -3
View File
@@ -200,8 +200,10 @@ export async function connect(id: string, opts: ConnectionOptions): Promise<void
}
}
const redis = new Redis(sentinelOptions)
let timer: ReturnType<typeof setTimeout>
redis.once('ready', () => {
clearTimeout(timer)
clients.set(id, { client: redis })
redis.once('close', () => {
if (clients.get(id)?.client === redis) {
@@ -211,10 +213,11 @@ export async function connect(id: string, opts: ConnectionOptions): Promise<void
resolve()
})
redis.once('error', (err: Error) => {
clearTimeout(timer)
redis.disconnect()
reject(err)
})
setTimeout(() => {
timer = setTimeout(() => {
if (!clients.has(id)) {
redis.disconnect()
reject(new Error('连接超时,请检查主机和端口'))
@@ -241,8 +244,10 @@ export async function connect(id: string, opts: ConnectionOptions): Promise<void
enableReadyCheck: true,
}
const redis = new Redis.Cluster([{ host: opts.host, port: opts.port }], clusterOptions)
let timer: ReturnType<typeof setTimeout>
redis.once('ready', () => {
clearTimeout(timer)
clients.set(id, { client: redis as any })
redis.once('close', () => {
if (clients.get(id)?.client === (redis as any)) {
@@ -252,10 +257,11 @@ export async function connect(id: string, opts: ConnectionOptions): Promise<void
resolve()
})
redis.once('error', (err: Error) => {
clearTimeout(timer)
redis.disconnect()
reject(err)
})
setTimeout(() => {
timer = setTimeout(() => {
if (!clients.has(id)) {
redis.disconnect()
reject(new Error('连接超时,请检查主机和端口'))
@@ -274,8 +280,10 @@ export async function connect(id: string, opts: ConnectionOptions): Promise<void
const redisOptions = buildRedisOptions(opts)
const redis = new Redis(redisOptions)
let timer: ReturnType<typeof setTimeout>
redis.once('ready', () => {
clearTimeout(timer)
clients.set(id, { client: redis, sshClient, tunnelServer })
// Auto-cleanup when connection is lost
redis.once('close', () => {
@@ -287,13 +295,14 @@ export async function connect(id: string, opts: ConnectionOptions): Promise<void
})
redis.once('error', (err: Error) => {
clearTimeout(timer)
redis.disconnect()
if (sshClient) sshClient.end()
if (tunnelServer) tunnelServer.close()
reject(err)
})
setTimeout(() => {
timer = setTimeout(() => {
if (!clients.has(id)) {
redis.disconnect()
if (sshClient) sshClient.end()
+17 -1
View File
@@ -1,7 +1,7 @@
// 值格式转换(解压/编码/自定义)
import { gunzipSync, inflateSync, brotliDecompressSync } from 'zlib'
import { execFile } from 'child_process'
import { writeFile, unlink, mkdtemp } from 'fs/promises'
import { writeFile, unlink, mkdtemp, rmdir } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
@@ -48,6 +48,15 @@ export function detectFormat(value: string): FormatType {
return 'text'
}
const ALLOWED_FORMATTERS = new Set([
'xxd',
'jq',
'python3',
'python',
'php',
'column'
])
export async function customFormat(
value: string,
command: string,
@@ -65,10 +74,17 @@ export async function customFormat(
const parts = cmd.split(/\s+/)
const exe = parts[0]
if (!ALLOWED_FORMATTERS.has(exe)) {
resolve(`[Custom formatter error: "${exe}" is not in the allowed formatter list]`)
return
}
const args = parts.slice(1)
execFile(exe, args, { timeout: 10000, maxBuffer: 10 * 1024 * 1024 }, async (err, stdout) => {
await unlink(tmpFile).catch(() => {})
await rmdir(tmpDir).catch(() => {})
if (err) {
resolve(`[Custom formatter error: ${err.message}]`)
} else {
+9
View File
@@ -76,3 +76,12 @@ export function stopAllPubSub(id: string): void {
stopSubscribe(id)
stopMonitor(id)
}
export function stopAllPubSubClients(): void {
for (const [id] of subClients) {
stopSubscribe(id)
}
for (const [id] of monitorClients) {
stopMonitor(id)
}
}
+1
View File
@@ -40,6 +40,7 @@ export interface ElectronAPI {
connect: (id: string, opts: any) => Promise<any>
disconnect: (id: string) => Promise<void>
execute: (id: string, command: string, args: string[]) => Promise<any>
executeUnsafe: (id: string, command: string, args: string[]) => Promise<any>
getInfo: (id: string) => Promise<string>
ping: (id: string) => Promise<string>
isConnected: (id: string) => Promise<boolean>
+2
View File
@@ -34,6 +34,8 @@ const electronAPI = {
disconnect: (id: string): Promise<void> => ipcRenderer.invoke('redis:disconnect', id),
execute: (id: string, command: string, args: string[]): Promise<any> =>
ipcRenderer.invoke('redis:execute', id, command, args),
executeUnsafe: (id: string, command: string, args: string[]): Promise<any> =>
ipcRenderer.invoke('redis:executeUnsafe', id, command, args),
getInfo: (id: string): Promise<string> => ipcRenderer.invoke('redis:getInfo', id),
scanKeys: (id: string, cursor: string, pattern: string, count: number): Promise<any> =>
ipcRenderer.invoke('redis:scanKeys', id, cursor, pattern, count),
@@ -51,7 +51,7 @@ async function execute() {
try {
// Handle MULTI
if (command === 'MULTI' && !txMode.value) {
const result = await window.electronAPI.redis.execute(connStore.activeId, command, args)
const result = await window.electronAPI.redis.executeUnsafe(connStore.activeId, command, args)
if (result === 'OK') {
txMode.value = true
txQueue.value = []
@@ -64,7 +64,7 @@ async function execute() {
// Handle EXEC (end transaction)
if (command === 'EXEC' && txMode.value) {
const result = await window.electronAPI.redis.execute(connStore.activeId, command, args)
const result = await window.electronAPI.redis.executeUnsafe(connStore.activeId, command, args)
let formatted = ''
if (Array.isArray(result)) {
formatted = result.map((item: any, i: number) => {
@@ -84,7 +84,7 @@ async function execute() {
// Handle DISCARD (cancel transaction)
if (command === 'DISCARD' && txMode.value) {
await window.electronAPI.redis.execute(connStore.activeId, command, args)
await window.electronAPI.redis.executeUnsafe(connStore.activeId, command, args)
txMode.value = false
txQueue.value = []
history.value.push({ cmd, result: t('cli.txDiscarded') })
@@ -93,7 +93,7 @@ async function execute() {
// In transaction mode: queue command
if (txMode.value && !['MULTI', 'EXEC', 'DISCARD', 'WATCH', 'UNWATCH'].includes(command)) {
const result = await window.electronAPI.redis.execute(connStore.activeId, command, args)
const result = await window.electronAPI.redis.executeUnsafe(connStore.activeId, command, args)
txQueue.value.push(cmd)
const queueInfo = t('cli.txQueue', { n: txQueue.value.length })
history.value.push({ cmd, result: `${t('cli.txQueued')}${queueInfo}` })
@@ -138,7 +138,7 @@ async function execute() {
}
}
const result = await window.electronAPI.redis.execute(connStore.activeId, command, args)
const result = await window.electronAPI.redis.executeUnsafe(connStore.activeId, command, args)
let formatted = ''
if (result === null) {
@@ -245,7 +245,7 @@ async function importCommands() {
const cmdArgs = args.slice(1)
try {
const result = await window.electronAPI.redis.execute(connStore.activeId, command, cmdArgs)
const result = await window.electronAPI.redis.executeUnsafe(connStore.activeId, command, cmdArgs)
let formatted = ''
if (result === null) {
formatted = '(nil)'
@@ -234,7 +234,7 @@ async function handleFlushDb() {
inputErrorMessage: 'Type FLUSHDB to confirm',
})
if (input.value !== 'FLUSHDB') return
await window.electronAPI.redis.execute(connStore.activeId, 'FLUSHDB', [])
await window.electronAPI.redis.executeUnsafe(connStore.activeId, 'FLUSHDB', [])
ElMessage.success(t('status.flushDbDone'))
await refresh()
} catch {