feat(lock): main-window lock screen; harden env gating, sandbox and ssh teardown
Lock screen (main-window overlay, no second window): - scrypt password verifier in <userData>/lock.json (per-write salt, timingSafeEqual); salt/hash/password never leave the main process - lock now / idle auto-lock / lock at startup, growing failure cooldown, lock flags persisted so a quit-and-relaunch cannot bypass the lock - locked shell and body portals go inert while sessions keep running; menu accelerators (reload, DevTools, zoom) are swallowed while locked - settings gains a Lock tab; all copy in zh-CN/zh-TW/en/ja Security and stability: - packaged builds ignore ELECTRON_RENDERER_URL / OT_UPDATE_URL (devEnv) - renderer preload runs with sandbox: true - unreadable known_hosts store fails closed instead of being overwritten - connect-time secrets gated by the bookmark's auth method (connectPromptFor) - ssh stream teardown is idempotent: PTY_EXIT broadcasts exactly once - sysinfo polling is refcounted for split panes (forceStopPolling on close) - session-log index entries are path-contained; settings store writes atomically with EPERM/EBUSY retry - sync-changelog tolerates CRLF checkouts (was a silent no-op) - retry ssh2 host-key generation (flaky malformed key, ~1/500) Tests: lock-store + lock-controller suites; transport-death PTY_EXIT e2e; GitHub Actions CI (typecheck + 10 offline tests + build)
This commit is contained in:
1 parent
471f8c3e73
commit
35583b2c15
47 files changed
+3058
-105
No files matched your search
+68
-2
@@ -20,9 +20,9 @@
|
||||
|
||||
import { app, shell } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, statSync } from 'fs'
|
||||
import { appendFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { basename, dirname, join, resolve, sep } from 'path'
|
||||
import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
||||
import { DEFAULT_SETTINGS } from '../shared/settings'
|
||||
import { loadSettings } from './settingsStore'
|
||||
@@ -236,6 +236,9 @@ export class CommandsStore {
|
||||
typeof (x as SessionLogMeta).file === 'string'
|
||||
) {
|
||||
const meta = x as SessionLogMeta
|
||||
// index.json is data, not trust: a tampered or hand-edited `file`
|
||||
// must never turn logWrite into an arbitrary-path append.
|
||||
if (!this.isLoggableFile(meta.file)) continue
|
||||
this.metasByFile.set(meta.file, meta)
|
||||
if (meta.endedAt === undefined) this.activeBySession.set(meta.sessionId, meta)
|
||||
}
|
||||
@@ -245,6 +248,69 @@ export class CommandsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `file` resolves to a regular file inside the logs directory.
|
||||
*
|
||||
* Applied to every entry hydrated from index.json before it can ever reach
|
||||
* logWrite. `..` segments collapse via resolve(); containment is compared
|
||||
* case-insensitively on Windows so drive-letter or name-case spelling cannot
|
||||
* sneak a path past it. Symlinks are followed (realpathSync): an entry that
|
||||
* resolves outside the logs dir is dropped, and a path that exists but is
|
||||
* not a regular file (a directory, a device) is dropped too. A path that is
|
||||
* simply not on disk yet stays eligible — appendFile creates it lazily, and
|
||||
* the directory it would land in is still resolved.
|
||||
*/
|
||||
private isLoggableFile(file: string): boolean {
|
||||
if (file.length === 0) return false
|
||||
const dirReal = this.logsDirReal()
|
||||
let target: string
|
||||
let exists = true
|
||||
try {
|
||||
target = realpathSync(file)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false
|
||||
// Not on disk yet: appendFile would create it, so the directory it would
|
||||
// land in is what decides containment. Judging the literal path alone
|
||||
// would let a symlinked subdirectory point the append outside the dir.
|
||||
target = this.pendingPathReal(file)
|
||||
exists = false
|
||||
}
|
||||
if (exists) {
|
||||
try {
|
||||
if (!statSync(target).isFile()) return false
|
||||
} catch {
|
||||
// Vanished between realpath and stat: appends would recreate it, and
|
||||
// the containment check below already passed for this path.
|
||||
}
|
||||
}
|
||||
const norm = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p)
|
||||
const dirN = norm(dirReal)
|
||||
return norm(target).startsWith(dirN + sep)
|
||||
}
|
||||
|
||||
/** Real path of the logs dir; falls back to resolve() when it does not exist yet. */
|
||||
private logsDirReal(): string {
|
||||
try {
|
||||
return realpathSync(this.logsDir)
|
||||
} catch {
|
||||
return resolve(this.logsDir)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a log file that is not on disk yet would actually be written: its
|
||||
* parent resolved through any symlinks, the leaf kept as written (it does not
|
||||
* exist, so it has nothing to resolve). Falls back to the literal resolve()
|
||||
* when the parent is missing as well — the containment check then decides.
|
||||
*/
|
||||
private pendingPathReal(file: string): string {
|
||||
try {
|
||||
return join(realpathSync(dirname(file)), basename(file))
|
||||
} catch {
|
||||
return resolve(file)
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the full log index so listSessionLogs survives restart. */
|
||||
private persistIndex(): void {
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user