fix(windows): stop main-thread PowerShell ACL storm on env-store reads (#5011)

* fix(windows): stop main-thread PowerShell storm on env-store reads

Two changes fix the v1.4.52+ Windows performance regression (#4901 regression
against #4840) where 49 powershell.exe processes were spawned in 27 seconds
during load, saturating the Electron main thread and causing black terminals
and runtimeEnvironments:call timeouts.

Root cause: `readEnvironmentStore` calls `hardenExistingSecureFile` on every
read. The env-store parent directory's mtime churns constantly (every secure
write updates it), so the mtime-keyed idempotency cache never matched →
`bestEffortRestrictWindowsPath` (powershell, ~1-1.5s synchronous) fired on
every call. After #4901, the remote-runtime tab-sync loop reads the store
~2×/s, turning sporadic mtime misses into a continuous main-thread storm.

Fix 1 – path-cached directory hardening: add `hardenedDirectoryPathsThisProcess
(Set<string>)` that caches directory hardening by PATH for the process lifetime.
A directory's required ACL does not change when its mtime changes; only file
hardening retains the metadata-keyed cache so post-rename inode changes are
detected correctly.

Fix 2 – async ACL application: replace `execFileSync(powershell.exe, ...)` with
`execFile` (fire-and-forget). PowerShell cold-start is ~1-1.5s; the function is
already named `bestEffortRestrictWindowsPath` so async/optimistic caching is
correct. `applySecurePathRestriction` returns `true` optimistically on win32 so
the cache entry is written before the background process completes.

Tests: new regression tests verify the directory is hardened exactly once even
when its mtime changes between calls, that unchanged files are not re-hardened,
and that ACL application goes through async execFile (not execFileSync).

* fix(windows): apply credential-file ACL synchronously on write path

Follow-up rigor on the env-store PowerShell ACL storm fix (#5006). The
read-path storm fix (path-cached async directory hardening + async file
re-harden) is retained, but switching ALL ACL application to async opened a
narrow Windows-only security window: because writeFileSync({mode}) is a no-op
on Windows, writeSecureFile returned with the credential file still carrying
the parent directory's inherited (broader) ACL for the ~1-1.5s PowerShell
cold-start, affecting the e2ee keypair, device registry, and runtime env auth
store.

Fix: apply the credential FILE's ACL synchronously (execFileSync) on the
infrequent write path, before the atomic rename publishes it, and cache the
path as hardened only on confirmed success so a failed apply retries. Keep the
DIRECTORY hardening async + path-cached for the process lifetime (that is what
killed the #4901/#5006 main-thread storm). The read path's existing-file
re-harden stays async + metadata-cached (fires at most once per file, no storm).

Also:
- Document the dir-path cache process-lifetime known limitation (deleted+
  recreated dir not re-hardened until restart).
- Remove the redundant double dir-cache write in writeSecureFile.
- Add docs/windows-secure-file-acl-hardening.md describing the sync-file/
  async-dir model and a manual Windows e2e test plan (the cross-platform
  Playwright harness runs on Linux and cannot reach the PowerShell path).

Tests (src/shared/secure-file.test.ts, 13 passing): credential file hardened
synchronously while dir stays async (no async file-ACL window); failed sync
file-ACL apply is not cached and retries; dir hardened exactly once across many
writes despite mtime churn; no PowerShell spawned on non-win32.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep POSIX secure directory hardening metadata-aware

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Lesley Murfin 2026-06-09 02:47:29 -06:00 committed by GitHub
parent 98d02bca47
commit 4d6c291eff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 445 additions and 75 deletions

View File

@ -0,0 +1,104 @@
# Windows Secure-File ACL Hardening
## Problem
Credential files Orca writes on Windows (runtime env auth store, device registry,
e2ee keypair) must end up readable only by the current user, SYSTEM, and
Administrators. On POSIX this is a one-line `chmodSync`, but `writeFileSync`'s
`mode` option is a no-op on Windows, so the NTFS ACL has to be rewritten by
shelling out to PowerShell (`Get-Acl` / `Set-Acl`). PowerShell cold-start is
~11.5 s.
`readEnvironmentStore` calls `hardenExistingSecureFile` on every read. The
env-store parent directory's mtime churns constantly (every secure write updates
it), so an mtime-keyed idempotency cache never matches and a blocking PowerShell
spawn fires on every call. After the remote-runtime tab-sync polling change, the
store is read ~2×/s, turning sporadic mtime misses into a continuous main-thread
storm (~1.8 powershell.exe spawns/sec) that saturates the Electron main thread
and times out `runtimeEnvironments:call`. See #4901 / #5006.
## Model
`src/shared/secure-file.ts` applies two different caching + execution strategies,
chosen by whether the target is a directory and whether it is on the write path:
- **Directories — async + path-cached for the process lifetime.**
A directory's required ACL does not change when its mtime changes, so once a
directory has been hardened in this process it is trusted for the rest of the
process. Directory hardening uses fire-and-forget `execFile` so it never blocks
the main thread. This is the change that kills the #4901 storm.
- _Known limitation:_ a directory that is deleted and recreated mid-process is
not re-hardened until the next restart. The `.orca` secure dirs are not
deleted at runtime, so this is acceptable.
- **Credential files on the write path — synchronous, cache only on success.**
Because `writeFileSync({ mode })` is a no-op on Windows, a freshly written file
carries the parent directory's inherited (broader) ACL. `writeSecureFile` must
therefore restrict the file's ACL **synchronously** (via `execFileSync`) on the
temp file and on the renamed target before it returns — otherwise the function
would return with the credential briefly readable under inherited ACLs during
the ~11.5 s PowerShell cold-start window. The write path is infrequent, so the
synchronous cost is acceptable. The path is cached as hardened **only on
confirmed success**, so a failed apply is retried on the next write.
- **Existing files on the read path — async + metadata-cached.**
`hardenExistingSecureFile` re-asserts the ACL on an already-existing file at
most once per process (keyed on inode/size/timestamps so post-rename inode
changes are detected). Async is safe here because it only re-asserts an ACL on a
file that already exists; new files are hardened synchronously on the write path
above. Because it fires at most once per file, it does not storm.
The net effect: the frequent read path never blocks the main thread, while the
infrequent write path closes the async window so credential files are never
published with a broader-than-intended ACL.
## Requirements
- Read-path directory and existing-file hardening must not spawn PowerShell more
than once per path per process, regardless of mtime churn.
- `writeSecureFile` must apply the credential file's ACL synchronously before
returning; the file ACL must not be left to a background process.
- A failed synchronous file-ACL apply must not crash the write and must not be
cached as hardened (so it retries).
- No PowerShell is spawned on non-win32 platforms.
## Manual Windows end-to-end test plan
The automated e2e harness (`pnpm test:e2e`, Playwright `electron-headless`) runs
on `ubuntu-latest`, where `applySecurePathRestriction` short-circuits to
`chmodSync` and never reaches the PowerShell path. The ACL storm therefore cannot
be reproduced in the cross-platform e2e harness; verify it manually on Windows.
Pre-req: a Windows client paired to a remote `orca serve` runtime.
Watcher (PowerShell, run before launching Orca):
```powershell
while ($true) {
$n = (Get-CimInstance Win32_Process -Filter "Name='powershell.exe'").Count
"{0} powershell.exe count = {1}" -f (Get-Date -Format HH:mm:ss), $n
Start-Sleep -Milliseconds 500
}
```
Steps:
1. Launch the **stock v1.4.52/v1.4.53** build and open the remote workspace.
- **Before fix:** the watcher oscillates ~12 powershell.exe processes/sec
continuously through the load window; the app is unresponsive and the
`[web-session-tabs-sync] … RemoteRuntimeClientError: Timed out` error
appears in the console.
2. Launch the **fixed** build and open the same remote workspace.
- **After fix:** the watcher stays at `0` (no continuous powershell churn); the
env-store directory is hardened at most once; the app loads without the
session-tabs timeout.
3. Write a credential (e.g. sign in / register a device so a secure file is
written), then immediately inspect the file ACL:
```powershell
icacls "$env:APPDATA\orca\orca-environments.json"
```
- **Expected:** only the current user, `SYSTEM`, and `Administrators` have
access the instant the write completes (no inherited entries), confirming the
synchronous file-ACL apply closed the async window.

View File

@ -1,5 +1,5 @@
import { execFileSync } from 'child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
import { execFile, execFileSync } from 'child_process'
import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@ -12,7 +12,8 @@ import {
} from './secure-file'
vi.mock('child_process', () => ({
execFileSync: vi.fn()
execFileSync: vi.fn(),
execFile: vi.fn()
}))
describe('hardenSecurePath', () => {
@ -27,12 +28,23 @@ describe('hardenSecurePath', () => {
__resetSecureFileWindowsUserSidForTests()
__resetSecureFileHardenedPathsForTests()
vi.mocked(execFileSync).mockReset()
vi.mocked(execFile).mockReset()
// execFileSync handles whoami.exe (SID lookup) and the SYNCHRONOUS PowerShell file-ACL
// path used by writeSecureFile. The directory + read-path re-harden use async execFile.
vi.mocked(execFileSync).mockImplementation((file) => {
if (file === 'C:\\Windows\\System32\\whoami.exe') {
return '"USER","S-1-5-21-1000"'
}
// Synchronous PowerShell ACL apply succeeds (returns empty stdout).
return ''
})
// Directory + read-path PowerShell is called asynchronously; simulate immediate success
vi.mocked(execFile).mockImplementation((_file, _args, _opts, callback) => {
if (typeof callback === 'function') {
callback(null, '', '')
}
return {} as ReturnType<typeof execFile>
})
})
afterEach(() => {
@ -62,16 +74,16 @@ describe('hardenSecurePath', () => {
platform: 'win32'
})
// whoami.exe called synchronously to obtain SID
expect(execFileSync).toHaveBeenNthCalledWith(
1,
'C:\\Windows\\System32\\whoami.exe',
['/user', '/fo', 'csv', '/nh'],
expect.objectContaining({ encoding: 'utf-8' })
)
const [, powershellArgs, powershellOptions] = vi.mocked(execFileSync).mock.calls[1]!
expect(vi.mocked(execFileSync).mock.calls[1]![0]).toBe(
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
)
// PowerShell called asynchronously
const [powershellFile, powershellArgs, powershellOptions] = vi.mocked(execFile).mock.calls[0]!
expect(powershellFile).toBe('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe')
expect(powershellArgs).toEqual(
expect.arrayContaining([
'-NoProfile',
@ -87,24 +99,25 @@ describe('hardenSecurePath', () => {
expect(script).toContain('SetAccessRuleProtection($true, $false)')
expect(script).toContain('RemoveAccessRuleSpecific')
expect(script).toContain('Unexpected ACL entry')
expect(powershellOptions).toEqual(
expect.objectContaining({ stdio: 'ignore', windowsHide: true, timeout: 5000 })
)
expect(powershellOptions).toEqual(expect.objectContaining({ windowsHide: true, timeout: 5000 }))
})
it('adds inheritable rules when hardening a Windows directory', () => {
hardenSecurePath('C:\\Users\\me\\.orca', { isDirectory: true, platform: 'win32' })
const powershellArgs = vi.mocked(execFileSync).mock.calls[1]![1] as string[]
const powershellArgs = vi.mocked(execFile).mock.calls[0]![1] as string[]
expect(powershellArgs.at(-1)).toBe('1')
expect(powershellArgs[5]).toContain('ContainerInherit')
expect(powershellArgs[5]).toContain('ObjectInherit')
})
it('keeps Windows hardening best-effort when ACL rewriting fails', () => {
vi.mocked(execFileSync).mockImplementationOnce(() => '"USER","S-1-5-21-1000"')
vi.mocked(execFileSync).mockImplementationOnce(() => {
throw new Error('access denied')
// Simulate async PowerShell failure — the callback receives an error
vi.mocked(execFile).mockImplementationOnce((_file, _args, _opts, callback) => {
if (typeof callback === 'function') {
callback(new Error('access denied'), '', '')
}
return {} as ReturnType<typeof execFile>
})
expect(() =>
@ -125,6 +138,7 @@ describe('hardenSecurePath', () => {
hardenExistingSecureFile(targetPath)
hardenExistingSecureFile(targetPath)
// dir hardened once (path-cached), file hardened once (metadata-cached) — 2 total
expect(getPowerShellCalls()).toHaveLength(2)
expect(getPowerShellCalls().map(getPowerShellTarget)).toEqual([userDataPath, targetPath])
})
@ -141,35 +155,7 @@ describe('hardenSecurePath', () => {
writeFileSync(targetPath, '{"changed":true}')
hardenExistingSecureFile(targetPath)
expect(getPowerShellCalls()).toHaveLength(3)
expect(getPowerShellCalls().map(getPowerShellTarget)).toEqual([
userDataPath,
targetPath,
targetPath
])
})
it('retries existing-file hardening after a failed ACL rewrite', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-'))
tempDirs.push(userDataPath)
const targetPath = join(userDataPath, 'secret.json')
writeFileSync(targetPath, '{}')
let powershellCalls = 0
vi.mocked(execFileSync).mockImplementation((file) => {
if (file === 'C:\\Windows\\System32\\whoami.exe') {
return '"USER","S-1-5-21-1000"'
}
powershellCalls += 1
if (powershellCalls === 2) {
throw new Error('access denied')
}
return ''
})
hardenExistingSecureFile(targetPath)
hardenExistingSecureFile(targetPath)
// call 1: dir + file. call 2: dir skipped (path-cached), file re-hardened (new mtime)
expect(getPowerShellCalls()).toHaveLength(3)
expect(getPowerShellCalls().map(getPowerShellTarget)).toEqual([
userDataPath,
@ -187,17 +173,195 @@ describe('hardenSecurePath', () => {
writeSecureFile(targetPath, 'first')
writeSecureFile(targetPath, 'second')
const powershellTargets = getPowerShellCalls().map(getPowerShellTarget)
expect(powershellTargets).toHaveLength(5)
expect(powershellTargets.filter((entry) => entry === userDataPath)).toHaveLength(1)
expect(powershellTargets.filter((entry) => entry === targetPath)).toHaveLength(2)
// The DIRECTORY is hardened async + path-cached: exactly once across both writes.
const asyncTargets = getPowerShellCalls().map(getPowerShellTarget)
expect(asyncTargets).toEqual([userDataPath])
// The credential FILES (tmpFile + renamed target) are hardened SYNCHRONOUSLY on each write.
// write 1: tmpFile(1) + targetFile(1) = 2; write 2: tmpFile(1) + targetFile(1) = 2; total 4.
const syncTargets = getSyncPowerShellCalls().map(getPowerShellTarget)
expect(syncTargets).toHaveLength(4)
expect(syncTargets.filter((entry) => entry === targetPath)).toHaveLength(2)
// No directory should be hardened via the synchronous path.
expect(syncTargets.filter((entry) => entry === userDataPath)).toHaveLength(0)
})
// Regression test: #4901 — env-store reads at ~2×/s caused a PowerShell storm because the
// parent directory mtime churned (every secure write updates it), so the mtime-keyed cache
// never matched. Directories must be path-cached for the process lifetime.
it('does not re-harden the parent directory when its mtime changes between reads', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-'))
tempDirs.push(userDataPath)
const targetPath = join(userDataPath, 'secret.json')
writeFileSync(targetPath, '{}')
// Simulate the env-store read loop: hardenExistingSecureFile called many times while
// another part of Orca writes to the same directory (changing its mtime).
hardenExistingSecureFile(targetPath)
await waitForFileTimestampTick()
// Simulate a write to another file in the same dir (changes dir mtime)
writeFileSync(join(userDataPath, 'other.json'), '{}')
hardenExistingSecureFile(targetPath)
hardenExistingSecureFile(targetPath)
// The parent directory must be hardened exactly ONCE despite its mtime changing
const dirCalls = getPowerShellCalls().filter(
(call) => getPowerShellTarget(call) === userDataPath
)
expect(dirCalls).toHaveLength(1)
})
it('does not re-harden an unchanged file on repeated reads', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-'))
tempDirs.push(userDataPath)
const targetPath = join(userDataPath, 'secret.json')
writeFileSync(targetPath, '{}')
hardenExistingSecureFile(targetPath)
hardenExistingSecureFile(targetPath)
hardenExistingSecureFile(targetPath)
const fileCalls = getPowerShellCalls().filter(
(call) => getPowerShellTarget(call) === targetPath
)
expect(fileCalls).toHaveLength(1)
})
it('applies the read-path ACL asynchronously without blocking (async execFile)', () => {
hardenSecurePath('C:\\Users\\me\\.orca\\secret.json', {
isDirectory: false,
platform: 'win32'
})
// The default (read/dir) path must launch PowerShell via execFile (async), never sync.
expect(getSyncPowerShellCalls()).toHaveLength(0)
expect(getPowerShellCalls()).toHaveLength(1)
})
// Security regression guard (#5006 review finding): writeSecureFile must restrict the
// credential FILE's ACL SYNCHRONOUSLY before returning. On Windows writeFileSync({mode})
// is a no-op, so an async file ACL would leave the credential briefly readable under the
// parent's inherited (broader) ACL for the ~1-1.5s PowerShell cold-start window.
it('hardens the credential file synchronously while keeping the directory async', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-'))
tempDirs.push(userDataPath)
const targetPath = join(userDataPath, 'secret.json')
writeSecureFile(targetPath, 'contents')
// Directory: async only.
expect(getPowerShellCalls().map(getPowerShellTarget)).toEqual([userDataPath])
// File (tmpFile + renamed target): synchronous only — no async file ACL window.
const syncTargets = getSyncPowerShellCalls().map(getPowerShellTarget)
expect(syncTargets).toContain(targetPath)
expect(syncTargets.filter((entry) => entry === userDataPath)).toHaveLength(0)
// The final published target's ACL must have been applied via the synchronous path.
expect(getPowerShellCalls().map(getPowerShellTarget)).not.toContain(targetPath)
})
// Nit #1 (review): the synchronous file path must cache as hardened ONLY on confirmed
// success, so a failed ACL apply is retried on the next write instead of being silently
// trusted.
it('retries the credential-file ACL on the next write when the sync apply fails', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-'))
tempDirs.push(userDataPath)
const targetPath = join(userDataPath, 'secret.json')
// First write: the synchronous PowerShell ACL apply throws for every powershell call.
vi.mocked(execFileSync).mockImplementation((file) => {
if (file === 'C:\\Windows\\System32\\whoami.exe') {
return '"USER","S-1-5-21-1000"'
}
throw new Error('access denied')
})
expect(() => writeSecureFile(targetPath, 'first')).not.toThrow()
const firstWriteTargetCalls = getSyncPowerShellCalls()
.map(getPowerShellTarget)
.filter((entry) => entry === targetPath)
expect(firstWriteTargetCalls).toHaveLength(1)
// Second write: ACL apply now succeeds. Because the failed apply was NOT cached, the
// target file is hardened again rather than skipped.
vi.mocked(execFileSync).mockImplementation((file) => {
if (file === 'C:\\Windows\\System32\\whoami.exe') {
return '"USER","S-1-5-21-1000"'
}
return ''
})
writeSecureFile(targetPath, 'second')
const allTargetCalls = getSyncPowerShellCalls()
.map(getPowerShellTarget)
.filter((entry) => entry === targetPath)
expect(allTargetCalls).toHaveLength(2)
})
// Nit #2 (review) / hardening: the process-lifetime directory cache hardens a directory
// exactly once even when its mtime churns across many writes (the #4901 storm condition,
// exercised through the write path rather than the read path).
it('hardens the directory exactly once across many writes despite mtime churn', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-'))
tempDirs.push(userDataPath)
for (let i = 0; i < 5; i++) {
// Each write changes the directory's mtime (a new file lands in it).
writeSecureFile(join(userDataPath, `secret-${i}.json`), `contents-${i}`)
}
const dirCalls = getPowerShellCalls().filter(
(call) => getPowerShellTarget(call) === userDataPath
)
expect(dirCalls).toHaveLength(1)
})
// win32-only guard: on non-win32 platforms no PowerShell is ever spawned (sync or async);
// POSIX hardening uses chmodSync only.
it('never spawns PowerShell on non-win32 platforms', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-'))
tempDirs.push(userDataPath)
const targetPath = join(userDataPath, 'secret.json')
writeSecureFile(targetPath, 'contents')
hardenExistingSecureFile(targetPath)
expect(getPowerShellCalls()).toHaveLength(0)
expect(getSyncPowerShellCalls()).toHaveLength(0)
})
it('re-hardens a POSIX directory when its metadata changes after caching', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-'))
tempDirs.push(userDataPath)
const targetPath = join(userDataPath, 'secret.json')
writeFileSync(targetPath, '{}')
hardenExistingSecureFile(targetPath)
expect(statMode(userDataPath)).toBe(0o700)
chmodSync(userDataPath, 0o755)
hardenExistingSecureFile(targetPath)
expect(statMode(userDataPath)).toBe(0o700)
})
})
const POWERSHELL_SUFFIX = 'WindowsPowerShell\\v1.0\\powershell.exe'
// Async PowerShell calls (directory hardening + read-path file re-harden).
function getPowerShellCalls(): unknown[][] {
return vi.mocked(execFile).mock.calls.filter(([file]) => String(file).endsWith(POWERSHELL_SUFFIX))
}
// Synchronous PowerShell calls (credential-file ACL on the write path).
function getSyncPowerShellCalls(): unknown[][] {
return vi
.mocked(execFileSync)
.mock.calls.filter(([file]) => String(file).endsWith('WindowsPowerShell\\v1.0\\powershell.exe'))
.mock.calls.filter(([file]) => String(file).endsWith(POWERSHELL_SUFFIX))
}
function getPowerShellTarget(call: unknown[]): string {
@ -207,3 +371,7 @@ function getPowerShellTarget(call: unknown[]): string {
async function waitForFileTimestampTick(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 20))
}
function statMode(path: string): number {
return statSync(path).mode & 0o777
}

View File

@ -1,4 +1,4 @@
import { execFileSync } from 'child_process'
import { execFile, execFileSync } from 'child_process'
import { randomBytes } from 'crypto'
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
import { dirname, win32 as pathWin32 } from 'path'
@ -21,7 +21,39 @@ type HardenedPathCacheEntry = {
// always re-hardens (new inode) and then refreshes the cache entry.
const hardenedPathsThisProcess = new Map<string, HardenedPathCacheEntry>()
// Why: a directory's required ACL does not change because its mtime changed (child writes
// update the directory's mtime constantly). Keying the directory cache on mtime/ctime causes
// a cache miss — and a blocking PowerShell spawn — on every read-path call. We instead cache
// directory hardening by PATH for the entire process lifetime: once a directory's ACL has
// been applied in this process we trust it stays correct. Files keep the metadata-keyed cache
// so that post-rename inode changes are detected correctly. See #4901 regression report.
//
// Known limitation: because this is a process-lifetime path cache, a directory that is deleted
// and recreated during the same process will NOT be re-hardened. The secure dirs we own
// (.orca runtime/auth/device stores) are not deleted at runtime, so this is acceptable; the
// next process restart re-hardens. Do not rely on this cache if a path's lifecycle changes.
const hardenedDirectoryPathsThisProcess = new Set<string>()
function hardenSecureDirectoryOnce(dirPath: string): void {
// Why: directory hardening is async + path-cached. The directory ACL is broad-but-bounded
// (current user + SYSTEM + Administrators) and re-applying it is what stormed the main
// thread (#4901), so we never block on it. A pending dir ACL on first run is acceptable
// because the credential FILES inside it are hardened synchronously on the write path.
if (hardenedDirectoryPathsThisProcess.has(dirPath)) {
return
}
applySecurePathRestriction(dirPath, true, process.platform, false)
// Optimistic: cache even though the async ACL may still be in flight. The dir restriction
// is best-effort and re-running it does not improve security, so we accept no-retry here.
hardenedDirectoryPathsThisProcess.add(dirPath)
}
function hardenSecurePathOnce(targetPath: string, isDirectory: boolean): boolean {
if (isDirectory && process.platform === 'win32') {
hardenSecureDirectoryOnce(targetPath)
return true
}
const currentEntry = getHardenedPathCacheEntry(targetPath, isDirectory)
if (!currentEntry) {
hardenedPathsThisProcess.delete(targetPath)
@ -30,7 +62,11 @@ function hardenSecurePathOnce(targetPath: string, isDirectory: boolean): boolean
if (currentEntry && cachedEntry && hardenedPathCacheEntriesMatch(currentEntry, cachedEntry)) {
return true
}
if (applySecurePathRestriction(targetPath, isDirectory, process.platform)) {
// Why: the read path re-hardens an existing file at most once per process (metadata-cached
// above), so async file hardening here does not storm. The async restriction only re-asserts
// an ACL on a file that already exists; new credential files are hardened synchronously on
// the write path (see writeSecureFile), so there is no async window on creation.
if (applySecurePathRestriction(targetPath, isDirectory, process.platform, false)) {
rememberHardenedPath(targetPath, isDirectory)
return true
}
@ -46,7 +82,9 @@ export function writeSecureFile(targetPath: string, contents: string): void {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 })
}
const directoryWasHardened = hardenSecurePathOnce(dir, true)
// Windows directory hardening stays async + path-cached: it is what stormed the main thread
// (#4901). POSIX keeps the metadata cache so chmod/ctime changes are corrected.
hardenSecurePathOnce(dir, true)
const tmpFile = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp`
try {
@ -54,16 +92,20 @@ export function writeSecureFile(targetPath: string, contents: string): void {
encoding: 'utf-8',
mode: 0o600
})
hardenSecurePath(tmpFile, { isDirectory: false, platform: process.platform })
// Why: on Windows writeFileSync({mode:0o600}) is a no-op, so the file is created carrying
// the parent directory's inherited (broader) ACL. We must restrict the credential file's
// ACL SYNCHRONOUSLY before the atomic rename publishes it — otherwise writeSecureFile
// would return with the credential readable under inherited ACLs for the ~1-1.5s PowerShell
// cold-start window. The write path is infrequent, so the synchronous cost is acceptable;
// it is the READ path that stormed (#4901), and that stays async + cached.
applySecurePathRestriction(tmpFile, false, process.platform, true)
renameSync(tmpFile, targetPath)
// Why: these files carry runtime auth/device credentials; the published
// path must remain current-user only after the atomic rename.
if (applySecurePathRestriction(targetPath, false, process.platform)) {
// path must remain current-user only after the atomic rename. Apply synchronously and only
// cache on confirmed success so a failed ACL apply is retried on the next read/write.
if (applySecurePathRestriction(targetPath, false, process.platform, true)) {
rememberHardenedPath(targetPath, false)
}
if (directoryWasHardened) {
rememberHardenedPath(dir, true)
}
} catch (error) {
rmSync(tmpFile, { force: true })
throw error
@ -85,18 +127,36 @@ export function hardenSecurePath(
options: {
isDirectory: boolean
platform: NodeJS.Platform
sync?: boolean
}
): void {
applySecurePathRestriction(targetPath, options.isDirectory, options.platform)
applySecurePathRestriction(
targetPath,
options.isDirectory,
options.platform,
options.sync ?? false
)
}
function applySecurePathRestriction(
targetPath: string,
isDirectory: boolean,
platform: NodeJS.Platform
platform: NodeJS.Platform,
sync: boolean
): boolean {
if (platform === 'win32') {
return bestEffortRestrictWindowsPath(targetPath, isDirectory)
if (sync) {
// Why: the write path must apply the credential FILE's ACL before returning, otherwise
// the file is briefly readable under inherited (broader) ACLs (writeFileSync mode is a
// no-op on Windows). Run PowerShell synchronously and report real success so callers only
// cache the path as hardened when the ACL actually applied. The write path is infrequent.
return restrictWindowsPathSync(targetPath, isDirectory)
}
// Why: the directory and read-path re-harden are async (fire-and-forget) to avoid blocking
// the main thread (#4901). We optimistically return true because the restriction is
// best-effort; the cache entry is written immediately so we do not re-spawn on the next call.
bestEffortRestrictWindowsPath(targetPath, isDirectory)
return true
}
chmodSync(targetPath, isDirectory ? 0o700 : 0o600)
return true
@ -149,35 +209,72 @@ function hardenedPathCacheEntriesMatch(
)
}
function bestEffortRestrictWindowsPath(targetPath: string, isDirectory: boolean): boolean {
function buildWindowsRestrictAclArgs(
targetPath: string,
currentUserSid: string,
isDirectory: boolean
): string[] {
return [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
WINDOWS_RESTRICT_ACL_SCRIPT,
targetPath,
currentUserSid,
isDirectory ? '1' : '0'
]
}
function bestEffortRestrictWindowsPath(targetPath: string, isDirectory: boolean): void {
const currentUserSid = getCurrentWindowsUserSid()
if (!currentUserSid) {
return
}
// Why: execFile (async) is used instead of execFileSync to avoid blocking the Electron main
// thread. PowerShell cold-start is ~11.5 s; spawning it synchronously on every read-path
// call saturated the main thread in v1.4.52+ where the env-store is read ~2×/s by the
// remote-runtime tab-sync loop (#4901 regression). The restriction is best-effort
// (see function name), so it is safe to apply it in the background.
execFile(
getWindowsSystemToolPath('WindowsPowerShell\\v1.0\\powershell.exe'),
buildWindowsRestrictAclArgs(targetPath, currentUserSid, isDirectory),
{
windowsHide: true,
timeout: 5000
},
() => {
// Why: errors are intentionally ignored — credential-file hardening should not
// prevent Orca from starting on Windows machines where PowerShell ACL APIs are
// unavailable or locked down.
}
)
}
function restrictWindowsPathSync(targetPath: string, isDirectory: boolean): boolean {
const currentUserSid = getCurrentWindowsUserSid()
if (!currentUserSid) {
return false
}
// Why: synchronous variant for the credential-FILE write path only. The file must not be
// published (renamed into place / returned to the caller) until its ACL has actually been
// restricted, so we block here and report real success. This is the rare path; the frequent
// read path stays async (bestEffortRestrictWindowsPath) to avoid the #4901 main-thread storm.
try {
execFileSync(
getWindowsSystemToolPath('WindowsPowerShell\\v1.0\\powershell.exe'),
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
WINDOWS_RESTRICT_ACL_SCRIPT,
targetPath,
currentUserSid,
isDirectory ? '1' : '0'
],
buildWindowsRestrictAclArgs(targetPath, currentUserSid, isDirectory),
{
stdio: 'ignore',
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true,
timeout: 5000
}
)
return true
} catch {
// Why: credential-file hardening should not prevent Orca from starting on
// Windows machines where PowerShell ACL APIs are unavailable or locked down.
// Why: best-effort — a failed ACL apply (locked-down PowerShell, etc.) must not crash the
// write. Returning false leaves the path uncached so a later read/write retries it.
return false
}
}
@ -270,4 +367,5 @@ export function __resetSecureFileWindowsUserSidForTests(): void {
export function __resetSecureFileHardenedPathsForTests(): void {
hardenedPathsThisProcess.clear()
hardenedDirectoryPathsThisProcess.clear()
}