orca/src/shared/secure-path-windows-acl.ts

151 lines
4.8 KiB
TypeScript

import { execFile, execFileSync } from 'node:child_process'
import { win32 as pathWin32 } from 'node:path'
let cachedWindowsUserSid: string | null | undefined
function buildWindowsRestrictAclArgs(
targetPath: string,
currentUserSid: string,
isDirectory: boolean
): string[] {
return [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
WINDOWS_RESTRICT_ACL_SCRIPT,
targetPath,
currentUserSid,
isDirectory ? '1' : '0'
]
}
export function bestEffortRestrictWindowsPath(targetPath: string, isDirectory: boolean): void {
const currentUserSid = getCurrentWindowsUserSid()
if (!currentUserSid) {
return
}
// Why: async to avoid blocking the main thread — sync PowerShell cold-start (~1-1.5s) on the frequent read path stormed it (#4901).
execFile(
getWindowsSystemToolPath('WindowsPowerShell\\v1.0\\powershell.exe'),
buildWindowsRestrictAclArgs(targetPath, currentUserSid, isDirectory),
{
windowsHide: true,
timeout: 5000
},
() => {
// Why: ignore errors — hardening is best-effort; PowerShell ACL APIs may be unavailable or locked down.
}
)
}
export function restrictWindowsPathSync(targetPath: string, isDirectory: boolean): boolean {
const currentUserSid = getCurrentWindowsUserSid()
if (!currentUserSid) {
return false
}
// Why: file must not be published until its ACL is actually restricted, so block and report real success (read path stays async, #4901).
try {
execFileSync(
getWindowsSystemToolPath('WindowsPowerShell\\v1.0\\powershell.exe'),
buildWindowsRestrictAclArgs(targetPath, currentUserSid, isDirectory),
{
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true,
timeout: 5000
}
)
return true
} catch {
// Why: best-effort — a failed ACL apply must not crash the write; false leaves the path uncached to retry later.
return false
}
}
const WINDOWS_RESTRICT_ACL_SCRIPT = `
$ErrorActionPreference = 'Stop'
$path = $args[0]
$currentUserSid = $args[1]
$isDirectory = $args[2] -eq '1'
$allowedSidTexts = @($currentUserSid, 'S-1-5-18', 'S-1-5-32-544')
$allowedSids = @{}
foreach ($sidText in $allowedSidTexts) {
$allowedSids[$sidText] = $true
}
$acl = Get-Acl -LiteralPath $path
$acl.SetAccessRuleProtection($true, $false)
foreach ($rule in @($acl.Access)) {
[void]$acl.RemoveAccessRuleSpecific($rule)
}
$inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::None
if ($isDirectory) {
$inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit
}
foreach ($sidText in $allowedSidTexts) {
$sid = [System.Security.Principal.SecurityIdentifier]::new($sidText)
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
$sid,
[System.Security.AccessControl.FileSystemRights]::FullControl,
$inheritanceFlags,
[System.Security.AccessControl.PropagationFlags]::None,
[System.Security.AccessControl.AccessControlType]::Allow
)
[void]$acl.AddAccessRule($rule)
}
Set-Acl -LiteralPath $path -AclObject $acl
$verifiedAcl = Get-Acl -LiteralPath $path
if (-not $verifiedAcl.AreAccessRulesProtected) {
throw 'ACL inheritance is still enabled'
}
$fullControl = [System.Security.AccessControl.FileSystemRights]::FullControl
foreach ($rule in @($verifiedAcl.Access)) {
$sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
if (-not $allowedSids.ContainsKey($sid)) {
throw "Unexpected ACL entry $sid"
}
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) {
throw "Unexpected ACL deny entry $sid"
}
if (($rule.FileSystemRights -band $fullControl) -ne $fullControl) {
throw "ACL entry $sid does not grant FullControl"
}
}
`.trim()
function getCurrentWindowsUserSid(): string | null {
if (cachedWindowsUserSid !== undefined) {
return cachedWindowsUserSid
}
try {
const output = execFileSync(
getWindowsSystemToolPath('whoami.exe'),
['/user', '/fo', 'csv', '/nh'],
{
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
timeout: 5000
}
).trim()
const columns = parseCsvLine(output)
cachedWindowsUserSid = columns[1] ?? null
} catch {
cachedWindowsUserSid = null
}
return cachedWindowsUserSid
}
function getWindowsSystemToolPath(relativeSystem32Path: string): string {
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows'
return pathWin32.join(systemRoot, 'System32', relativeSystem32Path)
}
function parseCsvLine(line: string): string[] {
return line.split(/","/).map((part) => part.replace(/^"/, '').replace(/"$/, ''))
}
export function resetSecureFileWindowsUserSidForTests(): void {
cachedWindowsUserSid = undefined
}