fix(security): 汇总应用锁安全审计修复

严格校验应用锁 canonical 配置,并通过临时文件原子替换避免写入中断损坏配置。

补充密码、手动锁定、闲置锁定、启动锁定和崩溃恢复回归测试。
This commit is contained in:
xyz-225648 authored and digua committed 2026-07-18 11:49:09 +08:00
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 })
}
})
+24 -7
View File
@@ -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
}
}