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:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user