mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(security): 汇总应用锁安全审计修复
严格校验应用锁 canonical 配置,并通过临时文件原子替换避免写入中断损坏配置。 补充密码、手动锁定、闲置锁定、启动锁定和崩溃恢复回归测试。
This commit is contained in:
1 parent
84b371e73d
commit
7e46e47d3c
2 files changed
+158
-7
No files matched your search
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mock, test } from 'node:test'
|
||||
|
||||
class FakeMainWindow {
|
||||
readonly sentMessages: unknown[][] = []
|
||||
readonly webContents = {
|
||||
send: (...args: unknown[]) => this.sentMessages.push(args),
|
||||
}
|
||||
|
||||
isDestroyed(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function makeTempDir(): string {
|
||||
const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir())
|
||||
return fs.mkdtempSync(path.join(baseDir, 'chatlab-app-lock-'))
|
||||
}
|
||||
|
||||
test('desktop app lock persists its canonical config and handles manual, idle, and startup locking', async () => {
|
||||
const root = makeTempDir()
|
||||
const settingsDir = path.join(root, 'settings')
|
||||
const mainWindow = new FakeMainWindow()
|
||||
let systemIdleSeconds = 0
|
||||
let cleanupLockManager: (() => void) | undefined
|
||||
|
||||
await mock.module('electron', {
|
||||
namedExports: {
|
||||
powerMonitor: {
|
||||
getSystemIdleTime: () => systemIdleSeconds,
|
||||
},
|
||||
},
|
||||
})
|
||||
await mock.module('../paths/provider', {
|
||||
namedExports: {
|
||||
getPathProvider: () => ({ getSettingsDir: () => settingsDir }),
|
||||
},
|
||||
})
|
||||
await mock.module('../logger', {
|
||||
namedExports: {
|
||||
logger: {
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
debug: () => undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
fs.mkdirSync(settingsDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, 'app-lock.json'),
|
||||
JSON.stringify({ enabled: true, idleTimeoutMinutes: 0, lockOnStartup: false }),
|
||||
'utf-8'
|
||||
)
|
||||
fs.writeFileSync(path.join(settingsDir, '.app-lock-flag'), 'stale', 'utf-8')
|
||||
|
||||
const lockManager = await import('./lock-manager.js')
|
||||
cleanupLockManager = lockManager.cleanupLockManager
|
||||
lockManager.initLockManager(mainWindow as never)
|
||||
|
||||
assert.deepEqual(lockManager.getLockConfig(), {
|
||||
enabled: false,
|
||||
idleTimeoutMinutes: 0,
|
||||
lockOnStartup: false,
|
||||
})
|
||||
assert.equal(fs.existsSync(path.join(settingsDir, '.app-lock-flag')), false)
|
||||
|
||||
assert.deepEqual(await lockManager.setPassword('123'), { success: false, error: 'invalid-pin' })
|
||||
assert.deepEqual(await lockManager.setPassword('1234'), { success: true })
|
||||
assert.deepEqual(lockManager.getLockConfig(), {
|
||||
enabled: true,
|
||||
idleTimeoutMinutes: 0,
|
||||
lockOnStartup: false,
|
||||
})
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(path.join(settingsDir, 'app-lock.json'), 'utf-8')) as {
|
||||
passwordHash: { hash: string; salt: string; version: number }
|
||||
}
|
||||
assert.deepEqual(Object.keys(persisted).sort(), ['idleTimeoutMinutes', 'lockOnStartup', 'passwordHash'])
|
||||
assert.equal(persisted.passwordHash.version, 1)
|
||||
assert.equal(persisted.passwordHash.hash.length, 128)
|
||||
assert.equal(persisted.passwordHash.salt.length, 64)
|
||||
|
||||
assert.deepEqual(lockManager.updateLockConfig({ enabled: true }), {
|
||||
success: false,
|
||||
error: 'invalid-config',
|
||||
})
|
||||
assert.equal(lockManager.updateLockConfig({ idleTimeoutMinutes: 5 }).success, true)
|
||||
assert.equal(lockManager.updateLockConfig({ idleTimeoutMinutes: 0 }).success, true)
|
||||
|
||||
assert.deepEqual(lockManager.lockApp(), { success: true })
|
||||
assert.equal(lockManager.getLockState(), 'locked')
|
||||
assert.deepEqual(await lockManager.unlockApp('0000'), {
|
||||
success: false,
|
||||
error: 'wrong-password',
|
||||
wrongPassword: true,
|
||||
})
|
||||
assert.deepEqual(await lockManager.unlockApp('1234'), { success: true })
|
||||
assert.deepEqual(mainWindow.sentMessages.slice(-2), [
|
||||
['app-lock-state-changed', true],
|
||||
['app-lock-state-changed', false],
|
||||
])
|
||||
|
||||
assert.deepEqual(await lockManager.changePassword('1234', '1234'), {
|
||||
success: false,
|
||||
error: 'same-password',
|
||||
})
|
||||
assert.deepEqual(await lockManager.changePassword('1234', '4321'), { success: true })
|
||||
|
||||
systemIdleSeconds = 60
|
||||
assert.equal(lockManager.updateLockConfig({ idleTimeoutMinutes: 1 }).success, true)
|
||||
assert.equal(lockManager.getLockState(), 'locked')
|
||||
systemIdleSeconds = 0
|
||||
assert.deepEqual(await lockManager.unlockApp('4321'), { success: true })
|
||||
|
||||
assert.equal(lockManager.updateLockConfig({ lockOnStartup: true }).success, true)
|
||||
lockManager.cleanupLockManager()
|
||||
lockManager.initLockManager(mainWindow as never)
|
||||
assert.equal(lockManager.getLockState(), 'locked')
|
||||
assert.deepEqual(await lockManager.unlockApp('4321'), { success: true })
|
||||
|
||||
assert.deepEqual(lockManager.resetAppLockPassword(), { success: true })
|
||||
assert.equal(lockManager.getLockConfig().enabled, false)
|
||||
assert.deepEqual(lockManager.lockApp(), { success: false, error: 'disabled' })
|
||||
} finally {
|
||||
cleanupLockManager?.()
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -65,15 +65,25 @@ function loadConfig(): boolean {
|
||||
}
|
||||
|
||||
const record = data as Record<string, unknown>
|
||||
const allowedKeys = new Set(['idleTimeoutMinutes', 'lockOnStartup', 'passwordHash'])
|
||||
if (Object.keys(record).some((key) => !allowedKeys.has(key))) {
|
||||
logger.warn('App lock config ignored because it contains unsupported fields')
|
||||
return false
|
||||
}
|
||||
if (!isValidIdleTimeout(record.idleTimeoutMinutes) || typeof record.lockOnStartup !== 'boolean') {
|
||||
logger.warn('App lock config ignored because its settings are invalid')
|
||||
return false
|
||||
}
|
||||
if (record.passwordHash !== undefined && !isPasswordHash(record.passwordHash)) {
|
||||
logger.warn('App lock config ignored because its password format is invalid')
|
||||
return false
|
||||
}
|
||||
|
||||
currentSettings = {
|
||||
idleTimeoutMinutes: isValidIdleTimeout(record.idleTimeoutMinutes)
|
||||
? record.idleTimeoutMinutes
|
||||
: DEFAULT_LOCK_SETTINGS.idleTimeoutMinutes,
|
||||
lockOnStartup:
|
||||
typeof record.lockOnStartup === 'boolean' ? record.lockOnStartup : DEFAULT_LOCK_SETTINGS.lockOnStartup,
|
||||
idleTimeoutMinutes: record.idleTimeoutMinutes,
|
||||
lockOnStartup: record.lockOnStartup,
|
||||
}
|
||||
storedPasswordHash = isPasswordHash(record.passwordHash) ? record.passwordHash : null
|
||||
storedPasswordHash = record.passwordHash ?? null
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`App lock config ignored because it is invalid: ${error instanceof Error ? error.message : error}`)
|
||||
@@ -83,15 +93,22 @@ function loadConfig(): boolean {
|
||||
|
||||
function saveConfig(settings: LockSettings, passwordHash: PasswordHash | null): boolean {
|
||||
const configPath = getLockConfigPath()
|
||||
const tempPath = `${configPath}.tmp`
|
||||
|
||||
try {
|
||||
fs.mkdirSync(getSettingsDir(), { recursive: true })
|
||||
const record: Record<string, unknown> = { ...settings }
|
||||
if (passwordHash) record.passwordHash = passwordHash
|
||||
fs.writeFileSync(configPath, JSON.stringify(record, null, 2), 'utf-8')
|
||||
fs.writeFileSync(tempPath, JSON.stringify(record, null, 2), 'utf-8')
|
||||
fs.renameSync(tempPath, configPath)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save app lock config: ${error instanceof Error ? error.message : error}`)
|
||||
try {
|
||||
fs.unlinkSync(tempPath)
|
||||
} catch {
|
||||
// 临时文件可能尚未创建。
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user