feat(scripting): ScriptWorker isolation on Windows: restricted worker identity, pipe ACL, firewall + PeCacheDir installer (lmxopcua#780)
v2-ci / unit-tests (push) Has been cancelled

Owner decisions §14 #5/#6 as built (design §12.5):

- Scripting:Worker:Identity (default unset, Windows only, fail-closed validation): each worker
  starts under a restricted token derived from the host's. Every privilege but SeChangeNotify is
  removed; the restricting SIDs are NT SERVICE\OtOpcUaScriptWorker's SID + Everyone + Users +
  RESTRICTED; the token and default DACLs are host + SYSTEM + identity + RESTRICTED. The worker
  is created suspended, put in its Job Object before it runs, inherits only its stdio pipes, gets
  an ACL'd work dir as TEMP, and runs with DOTNET_EnableDiagnostics=0. The identity is granted on
  the host's window station and desktop, because USER32 init made coreclr.dll fail with
  ERROR_DLL_INIT_FAILED on windev. The logon SID is not used because the host's process DACL
  gives it PROCESS_VM_READ.
- The worker pipe on Windows always has an explicit protected DACL: host account + identity
  only. There is no owner ACE, so an elevated host's Administrators gets no access.
- The worker treats access denied on its parent-pid poll as alive; the Job Object covers that case.
- scripts/install/Install-ScriptWorkerIsolation.ps1 + ScriptWorkerIsolation.psm1 are idempotent
  and support -WhatIf, -Uninstall and -PlanOnly. They create the disabled anchor service, the
  PeCacheDir ACL (protected, no Users, key and markers host-only, pe\ Modify for the identity),
  read+execute on script-worker\, a ReadData deny on the install root, the outbound firewall
  block on the worker exe, and optionally the host-service env.
- Tests: the dry-run harness scripts/install/tests/Test-ScriptWorkerIsolation.ps1 (46
  assertions, mutation-checked); ScriptWorkerIdentityTests; WorkerIsolationTests, which cover
  the pipe ACL, token access and a real restricted worker running a script, all green on windev.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 08:46:23 -05:00
parent 0e96dd08a2
commit 2a8e2981fe
20 changed files with 1898 additions and 37 deletions
+1 -1
View File
@@ -395,7 +395,7 @@ lists `ScriptedAlarm` — never by default) in `ZB.MOM.WW.OtOpcUa.ScriptWorker`
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess`, protocol in `Core.Scripting.Protocol`), shipped in `script-worker/`
beside the host (self-contained + started via its apphost on a `-r win-x64 --self-contained` publish, through the muxer otherwise); a runner-side failure publishes `BadResourceUnavailable` (ScadaBridge-visible, ScadaBridge#112) and the seam re-evaluates
when the worker is back. `IScriptRunner.RunBatch` sends ready evaluations as one frame per worker (Calc pump, VT coalescer,
alarm dirty set); a run queued behind busy worker threads has its deadlines restarted when it starts, so it is never blamed for a sibling. Optional `Scripting:Worker:PeCacheDir` = HMAC'd compiled-PE cache (restart without Roslyn); the `script-runner`
alarm dirty set); a run queued behind busy worker threads has its deadlines restarted when it starts, so it is never blamed for a sibling. Optional `Scripting:Worker:PeCacheDir` = HMAC'd compiled-PE cache (restart without Roslyn); optional (Windows) `Scripting:Worker:Identity` = `NT SERVICE\OtOpcUaScriptWorker` runs workers under a restricted token, set up by `scripts/install/Install-ScriptWorkerIsolation.ps1` (ACLs + outbound firewall block; `docs/ServiceHosting.md`); the `script-runner`
readiness check is at worst Degraded (never fails ready). The worker tests
are `tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests` (`Category=ScriptWorker`, real processes; `Category=Perf` harness opt-in via
`OTOPCUA_SCRIPT_PERF=1`). docker-dev overlay: `docker-dev/docker-compose.script-runner.yml`. See `docs/VirtualTags.md`
+20 -3
View File
@@ -101,13 +101,30 @@ or one inside the content root) on a directory only the service account can read
is at worst Degraded. A self-contained host publish (`dotnet publish … -r win-x64 --self-contained`, the Windows installs, which have no
.NET runtime) ships a **self-contained** worker in `script-worker/` (~97 MB: it carries its own runtime) and the host starts its
`ZB.MOM.WW.OtOpcUa.ScriptWorker.exe` apphost directly; a framework-dependent host (docker-dev) runs the worker's dll through the
dotnet muxer. The worker currently runs under the host's account; the dedicated low-privilege account,
pipe ACL and firewall outbound rule (design §14 #5/#6) are installer work still open on lmxopcua#780. See
`docs/VirtualTags.md` §"Out-of-process runner".
dotnet muxer. See `docs/VirtualTags.md` §"Out-of-process runner".
### Worker isolation on Windows (`Install-ScriptWorkerIsolation.ps1`, design §14 #5/#6)
`scripts/install/Install-ScriptWorkerIsolation.ps1 -InstallRoot <dir> -HostServiceAccount <account> [-ConfigureHostService]`
(idempotent; `-WhatIf` prints every change; `-Uninstall` rolls back; `-PlanOnly` prints the plan on any OS) sets up:
| Step | Change |
|---|---|
| Worker identity | A **disabled** anchor service `OtOpcUaScriptWorker` running as `NT SERVICE\OtOpcUaScriptWorker`, so the virtual account exists. It is never started. |
| `Scripting:Worker:Identity` | With `NT SERVICE\OtOpcUaScriptWorker` set (by `-ConfigureHostService`, or in appsettings), the host starts every worker under a **restricted token** derived from its own: every privilege but `SeChangeNotifyPrivilege` removed, and restricting SIDs = the identity + `Everyone` + `BUILTIN\Users` + `RESTRICTED`. Every access the worker makes must pass for the host account **and** for that set. The token's default DACL is host + SYSTEM + identity; the worker is created suspended and put in its Job Object before it runs; `TEMP`/`TMP` point at its per-launch working directory (ACL: host + SYSTEM full, identity Modify); `DOTNET_EnableDiagnostics=0`. The host also adds the identity to its own window station and desktop DACL (enumerate/read, and create-window on the desktop), once per process. USER32 connects to them in its DllMain and the runtime imports USER32, so without the grant `coreclr.dll` fails to load (`ERROR_DLL_INIT_FAILED`, measured on windev). The grant lives on the session's kernel objects and ends with the session; nothing persistent changes. The logon SID, which those objects normally grant, is deliberately not a restricting SID: the host's process DACL gives it `PROCESS_VM_READ`. Windows cannot start an ad-hoc child process *as* a virtual account (only the SCM can, for a service), and a separate local user would need a stored password plus `SeAssignPrimaryTokenPrivilege` on the host account; a restricted copy of the host's own token needs neither. Startup refuses the key off Windows or when malformed. |
| Pipe ACL | On Windows the worker pipe always carries an explicit, protected DACL: the host account and (with an identity) the worker identity, read/write, nobody else. No SYSTEM, no Administrators, no Everyone. The owner is the host token's owner, so the worker's `CurrentUserOnly` check of the server end still holds. |
| `PeCacheDir` | Created with a protected ACL: SYSTEM, Administrators and the host account full control, no Users. `pe\` grants the identity Modify (workers compile and write entries). The HMAC key and `workers\` markers are host-only. |
| Install folder | The identity is **denied** ReadData on `<InstallRoot>` (inherited), so the worker cannot read `appsettings*.json`, logs or host binaries even where `Users` can. `script-worker\` grants it read + execute. |
| Firewall | Outbound **block** rule `OtOpcUa-ScriptWorker-BlockOutbound` on `script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe`, every profile. It covers the self-contained apphost the Windows installs run. A framework-dependent worker runs under `dotnet.exe` and is not covered, so there the analyzer's `System.Net` ban is the only network control. |
The plan is pure data asserted by `scripts/install/tests/Test-ScriptWorkerIsolation.ps1` (`pwsh -File …`, any OS).
Re-run the installer after a deploy that replaces the install folder: a rename/swap drops the folder ACEs, but files
copied into the existing folder inherit them. Rollback: `-Uninstall -ConfigureHostService`, then restart the host.
## Install / Uninstall
- `scripts/install/Install-Services.ps1 -Roles admin,driver` — installs `OtOpcUaHost`.
- `scripts/install/Install-ScriptWorkerIsolation.ps1` — the ScriptWorker identity, ACLs and firewall rule (above); only for `Scripting:Runner` ≠ `InProcess`.
- `scripts/install/Uninstall-Services.ps1` — stops + removes the host service. (The historian backend is the external HistorianGateway — not installed/removed by these scripts.)
## Logging
+7 -3
View File
@@ -71,7 +71,7 @@ processes:
The runner **fails closed**: no worker means Bad, never an in-process run.
- **Containment.** The worker gets a cleared, allow-listed environment (no `ServerHistorian__ApiKey`, no
connection strings), an empty temp working directory, `DOTNET_GCHeapHardLimit = Scripting:Worker:HeapLimitBytes`
(512 MiB), a Windows Job Object (kill-on-close, one process, a commit backstop), a random current-user-only pipe
(512 MiB), a Windows Job Object (kill-on-close, one process, a commit backstop), a random pipe (current-user-only on Linux; an explicit host + worker-identity ACL on Windows)
with a launch nonce in its environment, and no Akka / gRPC assemblies in its folder. It exits on pipe end of stream
or when its parent pid disappears. Results cross as protobuf `Variant`s carrying the exact CLR type, so
`(short)ctx.GetTag(x).Value` behaves as in process; an input or result type with no tag (a `List<T>`, a `Guid`)
@@ -121,8 +121,12 @@ processes:
runtime's reference assemblies on disk), and the launcher starts its apphost `ZB.MOM.WW.OtOpcUa.ScriptWorker.exe`
directly: `OutOfProcessScriptRunner.ResolveWorkerEntry` picks the apphost only when the worker's `runtimeconfig.json` lists
`includedFrameworks`. Gated on windev against a real `-r win-x64` publish (`OTOPCUA_SCRIPTWORKER_TEST_PATH` points the
worker suite at the published apphost). Not yet done (lmxopcua#780): the low-privilege worker account, pipe ACL, firewall
rule and PeCacheDir ACL (installer).
worker suite at the published apphost).
- **Windows isolation** (`Scripting:Worker:Identity`, `scripts/install/Install-ScriptWorkerIsolation.ps1`): the worker runs
under a restricted, privilege-free token for the virtual account `NT SERVICE\OtOpcUaScriptWorker`. The pipe ACL admits
the host and that identity only. The install folder is denied to it, `PeCacheDir` is host-only apart from `pe\`, and an
outbound firewall block covers the worker exe. See `docs/ServiceHosting.md` §"Worker isolation on Windows". The live
install on QA is a deploy gate (lmxopcua#780).
- **docker-dev.** `docker-dev/docker-compose.script-runner.yml` turns `OutOfProcess` on for the central pair's
VirtualTag and Calculation seams. The 2026-11-10 live drill (runaway kill and quarantine, `kill -9` recovery, open circuit
with readiness still 200) is recorded in the design doc's rollout section.
@@ -647,6 +647,7 @@ per-shard state, restarts in the last hour and the quarantine list. Quarantines
| `Scripting:Worker:QuarantineAfterKills` | `2` | §5.3 |
| `Scripting:Worker:UnavailableGrace` | `5s` | §9 |
| `Scripting:Worker:PeCacheDir` | none (required when not `InProcess`) | §7; absolute, outside the content root, fail-closed |
| `Scripting:Worker:Identity` | none (host's token) | Windows only: `NT SERVICE\OtOpcUaScriptWorker` → restricted worker token + pipe ACL (§14 #5, record §12.5) |
**Phases** (each one is its own issue and PR):
@@ -1196,6 +1197,56 @@ one running the in-flight predicate, which is what the step needed.)
the state the rig was already in (most likely from the earlier drill's store loss), not something this drill did.
- The drill image `otopcua-host:w34-shadow` was removed afterwards. The site nodes, sql and traefik were not touched.
### 12.5 Installer record: worker identity, pipe ACL, firewall, PeCacheDir ACL (2026-11-10)
Owner decisions §14 #5 and #6, as built. Operator reference: `docs/ServiceHosting.md` §"Worker isolation on Windows".
- **Identity, deviating in mechanism from "run as a virtual account".** Windows starts a process as `NT SERVICE\<name>` only
when the SCM starts that service. A host cannot launch K + 1 children that way, each with its own nonce, pipe and Job
Object. A separate local user would need a stored password plus `SeAssignPrimaryTokenPrivilege` on the host account.
`Scripting:Worker:Identity` (default unset; Windows only; startup refuses it elsewhere or when malformed) therefore starts
each worker under a **restricted token** derived from the host's (`RestrictedWorkerLauncher`):
- `DISABLE_MAX_PRIVILEGE`, so only `SeChangeNotifyPrivilege` is left;
- restricting SIDs = the virtual account's SID (computed from the name, `S-1-5-80-…`) + Everyone + Users + RESTRICTED;
- token and default DACL = host + SYSTEM + identity + RESTRICTED;
- created suspended and put in its Job Object before it runs, inheriting exactly its two stdio pipes;
- `TEMP` set to its ACL'd work directory, and `DOTNET_EnableDiagnostics=0`.
The installer registers a disabled anchor service, so the account exists and resolves by name.
- **Found on windev.** The runtime would not start under the restricted token (`coreclr.dll` → `ERROR_DLL_INIT_FAILED`). A
bisection over the token's groups showed that only the user or Administrators SID fixed it, not the logon SID. The cause
was USER32's window station and desktop connection, so the host now grants the identity on its own window station and
desktop. The logon SID stays out of the restricting set because the host's process DACL gives it `PROCESS_VM_READ`. The
token's own DACL also needed rewriting (the handle needs `WRITE_DAC`).
- **Pipe ACL.** On Windows the pipe always has an explicit, protected DACL: the host account and the identity, read/write,
nothing else. .NET's `CurrentUserOnly` grants the token *owner*, which is Administrators on an elevated host; this ACL
does not. The owner is still the token owner, so the worker's `CurrentUserOnly` client check is unchanged.
- **Installer** `scripts/install/Install-ScriptWorkerIsolation.ps1` + `ScriptWorkerIsolation.psm1`. It is idempotent, with
`-WhatIf`, `-Uninstall` and `-PlanOnly`, and it sets up:
- the anchor service;
- `PeCacheDir`: protected, SYSTEM / Administrators / host full control, no Users, and the key and markers host-only;
- `pe\`: the identity gets Modify;
- `script-worker\`: the identity gets read+execute;
- **deny** ReadData for the identity on the install folder (appsettings, logs, host binaries);
- outbound **block** firewall rule `OtOpcUa-ScriptWorker-BlockOutbound` on the worker apphost;
- optional host-service env (`-ConfigureHostService`).
The runner flip itself is not part of it.
- **Tests.**
- `scripts/install/tests/Test-ScriptWorkerIsolation.ps1` is the dry-run harness. It runs on any OS and asserts ids,
order, targets, SDDL and the rollback (46 assertions). A mutation check (deny → allow, protected → inherited) turns
4 red.
- `ScriptWorkerIdentityTests` (Core.Scripting) pin the SID algorithm against `NT SERVICE\TrustedInstaller`. The
PowerShell harness pins the same default SID.
- `WorkerIsolationTests` (ScriptWorker) cover the pipe ACL (built and live), the token, and a real worker under the
identity running a script with a restricted token. A plain worker's token is unrestricted (positive control).
- The token case is restricted, privilege-free, and impersonated: it reads System32 and writes its work dir, but
cannot read or write the host's temp.
- They ran green on windev without changing machine state, apart from the session's window station / desktop ACE.
No account, firewall rule or persistent ACL was created there.
- **Not verified live:** the installer on a real node. That is the QA deploy gate on lmxopcua#780's follow-up (see
that issue for the checklist).
---
## 13. Test plan
@@ -0,0 +1,142 @@
<#
.SYNOPSIS
Isolates the OtOpcUa ScriptWorker processes on a Windows node (lmxopcua#780, design §6, owner decisions §14 #5/#6):
the worker's virtual-account identity, the PeCacheDir ACL, an outbound firewall block on the worker executable and
a deny on the install folder. Idempotent; -WhatIf prints every change; -Uninstall rolls it back.
.DESCRIPTION
Only needed when the node runs Scripting:Runner = OutOfProcess or Shadow. It changes, in order:
1. AnchorService a DISABLED service '<WorkerServiceName>' whose account is NT SERVICE\<WorkerServiceName>,
so that virtual account exists and resolves by name. It is never started: the host launches
every worker itself, under a restricted token whose restricting SIDs are that account +
Everyone + BUILTIN\Users + RESTRICTED, with every privilege removed
(Scripting:Worker:Identity; RestrictedWorkerLauncher.cs). Each access check the worker
makes must pass for the host account AND that set.
2. PeCacheDir created; ACL replaced: protected, owner Administrators, SYSTEM + Administrators + the host
account full control. No Users, no worker access (the HMAC key and launch markers live here).
3. PeEntriesDir <PeCacheDir>\pe, the worker identity gets Modify (workers compile and write HMAC'd entries).
4. WorkerFolderAcl <InstallRoot>\script-worker, the worker identity gets read + execute.
5. InstallRootDeny <InstallRoot>, the worker identity is DENIED ReadData/ListDirectory (inherited), so the
worker cannot read appsettings*.json, the logs or the host binaries even where Users can.
6. FirewallBlockOutbound a Windows Firewall rule blocking all outbound traffic from
<InstallRoot>\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe (every profile). The
self-contained Windows publish starts the worker through that apphost; a framework-dependent
worker (dotnet.exe) is NOT covered — the analyzer's System.Net ban is then the only control.
7. HostServiceEnv (-ConfigureHostService only) Scripting__Worker__Identity and Scripting__Worker__PeCacheDir
in the OtOpcUaHost service environment. Scripting:Runner itself is NOT set here: the
runner flip is a separate, per-tier decision (design §12, P2/P3).
Re-run it after any deploy that REPLACES the install folder (a rename/swap drops the folder ACEs; files copied into
an existing folder inherit them). The host must be restarted for -ConfigureHostService to take effect.
.PARAMETER InstallRoot
The host install folder (holds OtOpcUa.Host.exe and script-worker\).
.PARAMETER HostServiceAccount
The OtOpcUaHost service account: DOMAIN\name, NT SERVICE\name, LocalSystem, or a SID. Gets full control of PeCacheDir.
.PARAMETER PeCacheDir
The compiled-PE cache (Scripting:Worker:PeCacheDir). Absolute and outside InstallRoot (the host refuses otherwise).
Default: $env:ProgramData\OtOpcUa\script-pe-cache.
.PARAMETER WorkerServiceName
The worker identity's virtual account name. Default OtOpcUaScriptWorker (Scripting:Worker:Identity =
'NT SERVICE\OtOpcUaScriptWorker').
.PARAMETER ConfigureHostService
Also write the two Scripting__Worker__* entries into the host service's environment (or remove them with -Uninstall).
.PARAMETER Uninstall
Roll everything back: the firewall rule, every explicit ACE for the worker identity, the anchor service, and (with
-ConfigureHostService) the env entries. The PE cache is kept unless -RemovePeCache.
.PARAMETER PlanOnly
Print the plan and return it as objects without touching (or even reading) the machine. Runs on any OS; used by the
dry-run harness scripts/install/tests/Test-ScriptWorkerIsolation.ps1. -HostServiceAccount must then be a SID.
.EXAMPLE
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -WhatIf
.EXAMPLE
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService
.EXAMPLE
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService -Uninstall
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
[Parameter(Mandatory)] [string]$InstallRoot,
[Parameter(Mandatory)] [string]$HostServiceAccount,
[string]$PeCacheDir,
[ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker',
[string]$HostServiceName = 'OtOpcUaHost',
[switch]$ConfigureHostService,
[switch]$Uninstall,
[switch]$RemovePeCache,
[switch]$PlanOnly
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'ScriptWorkerIsolation.psm1') -Force
if (-not $PeCacheDir) {
$programData = if ($env:ProgramData) { $env:ProgramData } else { 'C:\ProgramData' }
$PeCacheDir = "$($programData.TrimEnd('\'))\OtOpcUa\script-pe-cache"
}
function Resolve-HostSid([string]$Account) {
if ($Account -match '^[Ss]-1-\d+(-\d+)+$') { return $Account.ToUpperInvariant() }
if ($PlanOnly) { throw "-PlanOnly needs -HostServiceAccount as a SID (no account lookup without the OS)" }
if ($Account -in 'LocalSystem', '.\LocalSystem', 'NT AUTHORITY\SYSTEM') { return 'S-1-5-18' }
if ($Account -match '^NT SERVICE\\(.+)$') { return (Get-OtOpcUaServiceSid -Name $Matches[1]) }
$name = $Account -replace '^\.\\', "$env:COMPUTERNAME\"
([System.Security.Principal.NTAccount]::new($name)).Translate([System.Security.Principal.SecurityIdentifier]).Value
}
$hostSid = Resolve-HostSid $HostServiceAccount
$plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSid -PeCacheDir $PeCacheDir `
-WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName `
-ConfigureHostService:$ConfigureHostService -Uninstall:$Uninstall -RemovePeCache:$RemovePeCache
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4})" -f `
$(if ($Uninstall) { 'ROLLBACK' } else { 'install' }), $WorkerServiceName, $workerSid, $HostServiceAccount, $hostSid)
if ($PlanOnly) {
foreach ($step in $plan) { Write-Host (" [plan] {0,-22} {1}" -f $step.Id, $step.Change) }
return $plan
}
if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { throw 'Install-ScriptWorkerIsolation.ps1 applies changes on Windows only (use -PlanOnly elsewhere)' }
if (-not $Uninstall -and -not (Test-Path -LiteralPath (Join-Path $InstallRoot 'script-worker'))) {
throw "$InstallRoot\script-worker not found - publish the Host (it carries script-worker\) before isolating it"
}
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $WhatIfPreference -and -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'run elevated (services, firewall rules and folder ACLs need an administrator)'
}
$results = foreach ($step in $plan) {
if ($PSCmdlet.ShouldProcess($step.Target, "$($step.Id): $($step.Change)")) {
$outcome = Invoke-ScriptWorkerIsolationStep -Step $step
Write-Host (" [{0,-9}] {1,-22} {2}" -f $outcome, $step.Id, $step.Target)
[pscustomobject]@{ Id = $step.Id; Target = $step.Target; Outcome = $outcome }
}
}
if (-not $WhatIfPreference) {
Write-Host ''
if ($Uninstall) {
Write-Host 'Rolled back. If the host still sets Scripting:Worker:Identity (appsettings or env), unset it and restart the host.'
}
else {
Write-Host 'Done. Host configuration for this node:'
Write-Host " Scripting:Worker:Identity = NT SERVICE\$WorkerServiceName"
Write-Host " Scripting:Worker:PeCacheDir = $PeCacheDir"
if (-not $ConfigureHostService) { Write-Host ' (not written: re-run with -ConfigureHostService, or set them in appsettings)' }
Write-Host ' Restart OtOpcUaHost for a configuration change to take effect.'
}
}
$results
+312
View File
@@ -0,0 +1,312 @@
<#
ScriptWorker isolation (lmxopcua#780, design §6 + owner decisions §14 #5/#6, §7 PeCacheDir ACL).
Pure planning functions (no OS calls — the dry-run harness runs them on any OS) plus the Windows apply step.
Install-ScriptWorkerIsolation.ps1 is the entry point; see its help.
The worker identity is the virtual account NT SERVICE\<WorkerServiceName>. Its SID is computed from the name
(Get-OtOpcUaServiceSid, the algorithm behind `sc.exe showsid`), and the host starts each worker with a restricted
token whose restricting SIDs are that identity + Everyone + BUILTIN\Users + RESTRICTED (ScriptWorkerIdentity.cs,
RestrictedWorkerLauncher.cs). Every access check the worker makes must therefore pass for the host account AND for
that set — which is why the ACLs below grant or deny the worker identity specifically.
#>
Set-StrictMode -Version Latest
# Access masks (FileSystemRights) used in the planned ACEs.
$script:FullControl = 0x1F01FF
$script:Modify = 0x1301BF # Modify | Synchronize
$script:ReadAndExecute = 0x1200A9 # ReadAndExecute | Synchronize
$script:ReadData = 0x000001 # ReadData (files) / ListDirectory (folders)
$script:FirewallRuleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
$script:WorkerExeName = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
$script:EnvIdentity = 'Scripting__Worker__Identity'
$script:EnvPeCacheDir = 'Scripting__Worker__PeCacheDir'
function Get-OtOpcUaServiceSid {
<#
.SYNOPSIS
The SID of the virtual account NT SERVICE\<Name>: S-1-5-80- + SHA-1 of the upper-cased name (UTF-16LE) read as
five little-endian UInt32 values. Identical to `sc.exe showsid <Name>` and to ScriptWorkerIdentity.ServiceSid.
#>
[CmdletBinding()]
param([Parameter(Mandatory)] [ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$Name)
$bytes = [System.Text.Encoding]::Unicode.GetBytes($Name.ToUpperInvariant())
$sha1 = [System.Security.Cryptography.SHA1]::Create()
try { $hash = $sha1.ComputeHash($bytes) } finally { $sha1.Dispose() }
$parts = for ($i = 0; $i -lt 5; $i++) { [BitConverter]::ToUInt32($hash, $i * 4) }
'S-1-5-80-' + ($parts -join '-')
}
function Format-OtOpcUaAce {
# One SDDL ACE string: (A|D;flags;0xMASK;;;SID).
param([ValidateSet('A', 'D')] [string]$Type, [string]$Flags, [int]$Mask, [string]$Sid)
'({0};{1};0x{2:x};;;{3})' -f $Type, $Flags, $Mask, $Sid
}
function Get-OtOpcUaPeCacheSddl {
<#
.SYNOPSIS
The PeCacheDir root's security descriptor: owner Administrators, PROTECTED (no inherited Users/Everyone/
Authenticated Users), SYSTEM + Administrators + the host account full control (container + object inherit).
The worker identity gets NOTHING here — the HMAC key (pe-cache.key) and the launch markers (workers\) stay
host-only; the worker's one writable place is the pe\ subfolder (Get-OtOpcUaPeEntriesAce).
#>
param([Parameter(Mandatory)] [string]$HostSid)
$aces = @(
(Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid 'SY')
(Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid 'BA')
)
if ($HostSid -ne 'S-1-5-18') { $aces += Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid $HostSid }
'O:BAG:SYD:PAI' + ($aces -join '')
}
function Get-OtOpcUaPeEntriesAce {
# pe\ : the worker compiles in-process (owner decision §14 #7) and writes HMAC'd entries here — Modify, inherited.
param([Parameter(Mandatory)] [string]$WorkerSid)
Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:Modify -Sid $WorkerSid
}
function Get-OtOpcUaScriptWorkerFolderAce {
# script-worker\ : the worker's own binaries — read and execute, inherited.
param([Parameter(Mandatory)] [string]$WorkerSid)
Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:ReadAndExecute -Sid $WorkerSid
}
function Get-OtOpcUaInstallRootDenyAce {
<#
.SYNOPSIS
InstallRoot\ : DENY the worker identity ReadData/ListDirectory, inherited — so appsettings*.json, the logs and
the host binaries are unreadable to the worker even where BUILTIN\Users (one of its restricting SIDs) can read
them (Program Files grants Users read). script-worker\'s explicit allow is the nearer ACE and wins below it.
#>
param([Parameter(Mandatory)] [string]$WorkerSid)
Format-OtOpcUaAce -Type D -Flags 'OICI' -Mask $script:ReadData -Sid $WorkerSid
}
function New-OtOpcUaPlanStep {
param([string]$Id, [string]$Target, [string]$Change, [hashtable]$Data = @{})
[pscustomobject]@{ Id = $Id; Target = $Target; Change = $Change; Data = $Data }
}
function Get-ScriptWorkerIsolationPlan {
<#
.SYNOPSIS
Every change Install-ScriptWorkerIsolation.ps1 makes, in order, as data. Pure: no OS call, so the dry-run harness
(scripts/install/tests/Test-ScriptWorkerIsolation.ps1) asserts it on any OS and -WhatIf prints it verbatim.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$InstallRoot,
[Parameter(Mandatory)] [string]$HostSid,
[Parameter(Mandatory)] [string]$PeCacheDir,
[string]$WorkerServiceName = 'OtOpcUaScriptWorker',
[string]$HostServiceName = 'OtOpcUaHost',
[switch]$ConfigureHostService,
[switch]$Uninstall,
[switch]$RemovePeCache
)
# Windows paths, planned on any OS (the harness runs on macOS/Linux too): drive-rooted or UNC only.
foreach ($p in @{ InstallRoot = $InstallRoot; PeCacheDir = $PeCacheDir }.GetEnumerator()) {
if ($p.Value -notmatch '^([A-Za-z]:\\|\\\\[^\\]+\\)') { throw "$($p.Key) '$($p.Value)' must be an absolute Windows path" }
}
$root = $InstallRoot.TrimEnd('\')
$cache = $PeCacheDir.TrimEnd('\')
if ($cache -ieq $root -or $cache.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw "PeCacheDir '$PeCacheDir' is inside InstallRoot '$InstallRoot'; the host refuses a cache inside its content root"
}
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
$identity = "NT SERVICE\$WorkerServiceName"
$workerFolder = "$root\script-worker"
$workerExe = "$workerFolder\$($script:WorkerExeName)"
$entries = "$cache\pe"
$steps = [System.Collections.Generic.List[object]]::new()
if (-not $Uninstall) {
$steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName `
"register the disabled anchor service '$WorkerServiceName' so the virtual account '$identity' ($workerSid) resolves by name (never started)" `
@{ Name = $WorkerServiceName; Identity = $identity; BinPath = $workerExe }))
$steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache `
"create the PE cache directory and set its ACL (protected; SYSTEM, Administrators, host account full control; no Users; no worker access to the key or markers)" `
@{ Path = $cache; Sddl = (Get-OtOpcUaPeCacheSddl -HostSid $HostSid) }))
$steps.Add((New-OtOpcUaPlanStep 'PeEntriesDir' $entries `
"create the PE entries directory and grant the worker identity Modify (it compiles and writes HMAC'd entries)" `
@{ Path = $entries; Ace = (Get-OtOpcUaPeEntriesAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:Modify; Type = 'Allow' }))
$steps.Add((New-OtOpcUaPlanStep 'WorkerFolderAcl' $workerFolder `
"grant the worker identity read+execute on its own binaries" `
@{ Path = $workerFolder; Ace = (Get-OtOpcUaScriptWorkerFolderAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:ReadAndExecute; Type = 'Allow' }))
$steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root `
"deny the worker identity ReadData/ListDirectory on the install folder (appsettings, logs, host binaries); script-worker\ stays readable" `
@{ Path = $root; Ace = (Get-OtOpcUaInstallRootDenyAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:ReadData; Type = 'Deny' }))
$steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName `
"add a Windows Firewall rule blocking ALL outbound traffic from '$workerExe' (every profile)" `
@{ Name = $script:FirewallRuleName; Program = $workerExe }))
if ($ConfigureHostService) {
$steps.Add((New-OtOpcUaPlanStep 'HostServiceEnv' $HostServiceName `
"set $($script:EnvIdentity)=$identity and $($script:EnvPeCacheDir)=$cache in the '$HostServiceName' service environment (other entries kept; takes effect on the next service start)" `
@{ Service = $HostServiceName; Set = [ordered]@{ $script:EnvIdentity = $identity; $script:EnvPeCacheDir = $cache } }))
}
}
else {
if ($ConfigureHostService) {
$steps.Add((New-OtOpcUaPlanStep 'HostServiceEnv' $HostServiceName `
"remove $($script:EnvIdentity) and $($script:EnvPeCacheDir) from the '$HostServiceName' service environment (workers revert to the host's token on the next start)" `
@{ Service = $HostServiceName; Remove = @($script:EnvIdentity, $script:EnvPeCacheDir) }))
}
$steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName `
"remove the Windows Firewall rule '$($script:FirewallRuleName)'" @{ Name = $script:FirewallRuleName; Remove = $true }))
$steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root `
"remove every explicit ACE for the worker identity from the install folder" @{ Path = $root; Sid = $workerSid; Remove = $true }))
$steps.Add((New-OtOpcUaPlanStep 'WorkerFolderAcl' $workerFolder `
"remove every explicit ACE for the worker identity from script-worker\" @{ Path = $workerFolder; Sid = $workerSid; Remove = $true }))
if ($RemovePeCache) {
$steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache `
"delete the PE cache directory (HMAC key, entries, launch markers; the host recreates a fresh cache if still configured)" `
@{ Path = $cache; Remove = $true }))
}
else {
$steps.Add((New-OtOpcUaPlanStep 'PeEntriesDir' $entries `
"remove every explicit ACE for the worker identity from the PE entries directory (the cache itself is kept; -RemovePeCache deletes it)" `
@{ Path = $entries; Sid = $workerSid; Remove = $true }))
}
$steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName `
"delete the anchor service '$WorkerServiceName'" @{ Name = $WorkerServiceName; Remove = $true }))
}
$steps
}
# ---------------------------------------------------------------------------------------------------------------------
# Apply (Windows only). Each function is idempotent: it reads the current state, changes only what differs, and
# returns 'changed' or 'unchanged'. Callers gate every call with ShouldProcess, so -WhatIf runs none of them.
# ---------------------------------------------------------------------------------------------------------------------
function Set-OtOpcUaExplicitAce {
# Ensures exactly one explicit ACE for $Sid on $Path with the given type/mask/inheritance (or none when $Remove).
param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove)
if (-not (Test-Path -LiteralPath $Path)) {
if ($Remove) { return 'unchanged' }
throw "$Path does not exist"
}
$item = Get-Item -LiteralPath $Path
$acl = $item.GetAccessControl('Access')
$identity = [System.Security.Principal.SecurityIdentifier]::new($Sid)
$explicit = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object { $_.IdentityReference -eq $identity })
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
$wanted = if ($Remove) { @() } else {
, [System.Security.AccessControl.FileSystemAccessRule]::new($identity, [System.Security.AccessControl.FileSystemRights]$Mask,
$inherit, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]$Type)
}
$same = ($explicit.Count -eq $wanted.Count) -and (
$wanted.Count -eq 0 -or (
[int]$explicit[0].FileSystemRights -eq $Mask -and $explicit[0].AccessControlType -eq $wanted[0].AccessControlType -and
$explicit[0].InheritanceFlags -eq $inherit -and $explicit[0].PropagationFlags -eq 'None'))
if ($same) { return 'unchanged' }
$acl.PurgeAccessRules($identity)
foreach ($rule in $wanted) { $acl.AddAccessRule($rule) }
$item.SetAccessControl($acl)
'changed'
}
function Set-OtOpcUaStepAce {
param([hashtable]$Data)
if ($Data.ContainsKey('Remove')) { return (Set-OtOpcUaExplicitAce -Path $Data.Path -Sid $Data.Sid -Remove) }
Set-OtOpcUaExplicitAce -Path $Data.Path -Sid $Data.Sid -Mask $Data.Mask -Type $Data.Type
}
function Invoke-ScriptWorkerIsolationStep {
<#
.SYNOPSIS
Applies one plan step on Windows. Idempotent; returns 'changed' or 'unchanged'.
#>
[CmdletBinding()]
param([Parameter(Mandatory)] $Step)
$d = $Step.Data
switch ($Step.Id) {
'AnchorService' {
$svc = Get-Service -Name $d.Name -ErrorAction SilentlyContinue
if ($d.ContainsKey('Remove')) {
if (-not $svc) { return 'unchanged' }
& sc.exe delete $d.Name | Out-Null
if ($LASTEXITCODE -ne 0) { throw "sc.exe delete $($d.Name) failed ($LASTEXITCODE)" }
return 'changed'
}
$verb = if ($svc) { 'config' } else { 'create' }
$cfg = Get-CimInstance Win32_Service -Filter "Name='$($d.Name)'" -ErrorAction SilentlyContinue
if ($cfg -and $cfg.StartMode -eq 'Disabled' -and $cfg.StartName -ieq $d.Identity -and $cfg.PathName -eq "`"$($d.BinPath)`"") {
return 'unchanged'
}
& sc.exe $verb $d.Name binPath= "`"$($d.BinPath)`"" start= disabled obj= $d.Identity `
DisplayName= 'OtOpcUa ScriptWorker identity (anchor, never started)' | Out-Null
if ($LASTEXITCODE -ne 0) { throw "sc.exe $verb $($d.Name) failed ($LASTEXITCODE)" }
& sc.exe sidtype $d.Name unrestricted | Out-Null
& sc.exe description $d.Name 'Anchors the NT SERVICE virtual account OtOpcUa ScriptWorker processes are restricted to (lmxopcua#780). Never started: the OtOpcUaHost launches workers itself under a restricted token.' | Out-Null
return 'changed'
}
'PeCacheDir' {
if ($d.ContainsKey('Remove')) {
if (-not (Test-Path -LiteralPath $d.Path)) { return 'unchanged' }
Remove-Item -LiteralPath $d.Path -Recurse -Force
return 'changed'
}
$changed = $false
if (-not (Test-Path -LiteralPath $d.Path)) { New-Item -ItemType Directory -Path $d.Path -Force | Out-Null; $changed = $true }
$item = Get-Item -LiteralPath $d.Path
$current = $item.GetAccessControl('Access, Owner').GetSecurityDescriptorSddlForm('Access, Owner')
$desired = [System.Security.AccessControl.DirectorySecurity]::new()
$desired.SetSecurityDescriptorSddlForm($d.Sddl, 'Access, Owner')
if ($current -ne $desired.GetSecurityDescriptorSddlForm('Access, Owner')) {
$item.SetAccessControl($desired) # propagates to the key file, pe\ and workers\ (inherited ACEs)
$changed = $true
}
return $(if ($changed) { 'changed' } else { 'unchanged' })
}
'PeEntriesDir' {
if (-not $d.ContainsKey('Remove') -and -not (Test-Path -LiteralPath $d.Path)) {
New-Item -ItemType Directory -Path $d.Path -Force | Out-Null
}
return (Set-OtOpcUaStepAce -Data $d)
}
{ $_ -in 'WorkerFolderAcl', 'InstallRootDeny' } {
return (Set-OtOpcUaStepAce -Data $d)
}
'FirewallBlockOutbound' {
$rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue
if ($d.ContainsKey('Remove')) {
if (-not $rule) { return 'unchanged' }
Remove-NetFirewallRule -Name $d.Name
return 'changed'
}
if ($rule) {
$app = ($rule | Get-NetFirewallApplicationFilter).Program
if ($rule.Direction -eq 'Outbound' -and $rule.Action -eq 'Block' -and $rule.Enabled -eq 'True' -and
$rule.Profile -eq 'Any' -and $app -ieq $d.Program) { return 'unchanged' }
Remove-NetFirewallRule -Name $d.Name
}
New-NetFirewallRule -Name $d.Name -DisplayName 'OtOpcUa ScriptWorker - block outbound' `
-Description 'lmxopcua#780 design §6: OtOpcUa script workers never need the network; scripts are also denied System.Net at compile time.' `
-Direction Outbound -Action Block -Program $d.Program -Profile Any -Enabled True | Out-Null
return 'changed'
}
'HostServiceEnv' {
$key = "HKLM:\SYSTEM\CurrentControlSet\Services\$($d.Service)"
if (-not (Test-Path $key)) {
if ($d.ContainsKey('Remove')) { return 'unchanged' }
throw "service '$($d.Service)' is not installed (run Install-Services.ps1 first)"
}
$current = @((Get-ItemProperty -Path $key -Name Environment -ErrorAction SilentlyContinue).Environment | Where-Object { $_ })
$names = if ($d.ContainsKey('Remove')) { $d.Remove } else { @($d.Set.Keys) }
$next = @($current | Where-Object { $n = ($_ -split '=', 2)[0]; $names -notcontains $n })
if (-not $d.ContainsKey('Remove')) { foreach ($k in $d.Set.Keys) { $next += "$k=$($d.Set[$k])" } }
if ((@($current) -join "`n") -eq (@($next) -join "`n")) { return 'unchanged' }
Set-ItemProperty -Path $key -Name Environment -Type MultiString -Value ([string[]]$next)
return 'changed'
}
default { throw "unknown plan step '$($Step.Id)'" }
}
}
Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce,
Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Get-ScriptWorkerIsolationPlan,
Invoke-ScriptWorkerIsolationStep
@@ -0,0 +1,127 @@
<#
.SYNOPSIS
Dry-run harness for Install-ScriptWorkerIsolation.ps1 (lmxopcua#780). Asserts the planned changes — ids, order,
targets, SDDL and ACEs — WITHOUT touching the machine, on any OS with PowerShell 7 (`pwsh`). The repo has no Pester;
this is a plain script that exits non-zero on the first failed group.
.EXAMPLE
pwsh -NoProfile -File scripts/install/tests/Test-ScriptWorkerIsolation.ps1
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$installDir = Split-Path -Parent $here
Import-Module (Join-Path $installDir 'ScriptWorkerIsolation.psm1') -Force
$script:failures = 0
$script:passes = 0
function Assert([bool]$Condition, [string]$Message) {
if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red }
}
function Assert-Throws([scriptblock]$Block, [string]$Like, [string]$Message) {
try { & $Block; Assert $false "$Message (did not throw)" }
catch { Assert ($_.Exception.Message -like $Like) "$Message (threw '$($_.Exception.Message)')" }
}
$hostSid = 'S-1-5-21-1111111111-2222222222-3333333333-1001'
$workerSid = 'S-1-5-80-1846135827-3903057317-1998105452-3938448374-3023753135' # NT SERVICE\OtOpcUaScriptWorker
$root = 'C:\Program Files\OtOpcUa'
$cache = 'C:\ProgramData\OtOpcUa\script-pe-cache'
Write-Host 'Service SID algorithm'
Assert ((Get-OtOpcUaServiceSid -Name 'TrustedInstaller') -eq 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') `
'NT SERVICE\TrustedInstaller matches the well-known SID'
Assert ((Get-OtOpcUaServiceSid -Name 'OtOpcUaScriptWorker') -eq $workerSid) 'OtOpcUaScriptWorker SID (pinned; ScriptWorkerIdentityTests pins the same value)'
Assert ((Get-OtOpcUaServiceSid -Name 'otopcuascriptworker') -eq $workerSid) 'the name is case-insensitive'
Assert-Throws { Get-OtOpcUaServiceSid -Name 'bad name' } '*' 'a name with a space is refused'
Write-Host 'Install plan'
$plan = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService)
$ids = $plan.Id -join ','
Assert ($ids -eq 'AnchorService,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)"
$byId = @{}; foreach ($s in $plan) { $byId[$s.Id] = $s }
$anchor = $byId['AnchorService'].Data
Assert ($anchor.Identity -eq 'NT SERVICE\OtOpcUaScriptWorker') 'anchor service runs as its own virtual account'
Assert ($anchor.BinPath -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "anchor binPath ($($anchor.BinPath))"
Assert ($byId['AnchorService'].Change -like '*disabled*never started*') 'anchor is described as disabled and never started'
$sddl = $byId['PeCacheDir'].Data.Sddl
Assert ($sddl -eq "O:BAG:SYD:PAI(A;OICI;0x1f01ff;;;SY)(A;OICI;0x1f01ff;;;BA)(A;OICI;0x1f01ff;;;$hostSid)") "PeCacheDir SDDL ($sddl)"
Assert ($sddl.StartsWith('O:BAG:SYD:PAI')) 'PeCacheDir DACL is protected (no inherited Users/Everyone)'
foreach ($forbidden in 'BU', 'WD', 'AU', 'S-1-5-32-545', 'S-1-1-0', 'S-1-5-11', $workerSid) {
Assert (-not $sddl.Contains(";;;$forbidden)")) "PeCacheDir root grants nothing to $forbidden"
}
$sysSddl = Get-OtOpcUaPeCacheSddl -HostSid 'S-1-5-18'
Assert ($sysSddl -eq 'O:BAG:SYD:PAI(A;OICI;0x1f01ff;;;SY)(A;OICI;0x1f01ff;;;BA)') "a LocalSystem host gets no duplicate ACE ($sysSddl)"
$pe = $byId['PeEntriesDir'].Data
Assert ($pe.Path -eq "$cache\pe" -or $pe.Path -eq "$cache/pe") "entries dir is <PeCacheDir>\pe ($($pe.Path))"
Assert ($pe.Ace -eq "(A;OICI;0x1301bf;;;$workerSid)") "worker gets Modify on pe\ ($($pe.Ace))"
Assert ($pe.Type -eq 'Allow' -and $pe.Mask -eq 0x1301BF -and $pe.Sid -eq $workerSid) 'pe\ ACE data matches its SDDL'
$wf = $byId['WorkerFolderAcl'].Data
Assert ($wf.Ace -eq "(A;OICI;0x1200a9;;;$workerSid)") "worker gets read+execute on script-worker\ ($($wf.Ace))"
Assert ($wf.Path -like '*\OtOpcUa\script-worker' -or $wf.Path -like '*/script-worker') "worker folder path ($($wf.Path))"
$deny = $byId['InstallRootDeny'].Data
Assert ($deny.Ace -eq "(D;OICI;0x1;;;$workerSid)") "install root DENIES the worker ReadData ($($deny.Ace))"
Assert ($deny.Type -eq 'Deny' -and $deny.Path -eq $root) 'install-root ACE is a deny on the root'
$fw = $byId['FirewallBlockOutbound'].Data
Assert ($fw.Program -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "firewall rule program ($($fw.Program))"
Assert ($byId['FirewallBlockOutbound'].Change -like '*blocking ALL outbound*') 'firewall rule blocks outbound'
$envSet = $byId['HostServiceEnv'].Data.Set
Assert ($envSet['Scripting__Worker__Identity'] -eq 'NT SERVICE\OtOpcUaScriptWorker') 'env: Scripting__Worker__Identity'
Assert ($envSet['Scripting__Worker__PeCacheDir'] -eq $cache) 'env: Scripting__Worker__PeCacheDir'
Assert (-not ($envSet.Keys -contains 'Scripting__Runner')) 'the runner flip is NOT part of the installer'
$noEnv = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache)
Assert (-not ($noEnv.Id -contains 'HostServiceEnv')) 'without -ConfigureHostService the host service is untouched'
Write-Host 'Path rules (positive and negative controls)'
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir "$root\cache" } '*inside InstallRoot*' 'a cache inside the install root is refused'
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $root } '*inside InstallRoot*' 'the install root itself is refused'
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'relative\cache' } '*absolute*' 'a relative cache is refused'
$sibling = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'C:\Program Files\OtOpcUa-cache')
Assert ($sibling.Count -eq 6) 'a sibling folder sharing the prefix is NOT inside the root (positive control)'
Write-Host 'Uninstall plan'
$un = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService -Uninstall)
$unIds = $un.Id -join ','
Assert ($unIds -eq 'HostServiceEnv,FirewallBlockOutbound,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,AnchorService') "rollback order ($unIds)"
Assert (@($un | Where-Object { -not ($_.Data.ContainsKey('Remove')) }).Count -eq 0) 'every rollback step removes'
Assert ((@($un.Id | Sort-Object) -join ',') -eq (@($plan.Id | Where-Object { $_ -ne 'PeCacheDir' } | Sort-Object) -join ',')) `
'rollback reverses every install step but the cache (kept by default)'
Assert (($un | Where-Object Id -eq 'HostServiceEnv').Data.Remove -contains 'Scripting__Worker__Identity') 'rollback removes the identity env entry'
foreach ($s in $un | Where-Object { $_.Id -in 'InstallRootDeny', 'WorkerFolderAcl', 'PeEntriesDir' }) {
Assert ($s.Data.Sid -eq $workerSid) "rollback $($s.Id) targets only the worker identity's ACEs"
}
$unAll = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -Uninstall -RemovePeCache)
Assert ($unAll.Id -contains 'PeCacheDir' -and -not ($unAll.Id -contains 'PeEntriesDir')) '-RemovePeCache deletes the cache instead of editing it'
Write-Host 'Entry script -PlanOnly (end to end, no machine access)'
$entry = Join-Path $installDir 'Install-ScriptWorkerIsolation.ps1'
$viaScript = @(& $entry -InstallRoot $root -HostServiceAccount $hostSid -PeCacheDir $cache -ConfigureHostService -PlanOnly 6>$null)
Assert (($viaScript.Id -join ',') -eq $ids) "-PlanOnly returns the module's plan ($($viaScript.Id -join ','))"
Assert-Throws { & $entry -InstallRoot $root -HostServiceAccount 'OTOPCUA\svc' -PeCacheDir $cache -PlanOnly 6>$null } '*needs -HostServiceAccount as a SID*' `
'-PlanOnly refuses an account name (no lookup without the OS)'
$defaultCache = @(& $entry -InstallRoot $root -HostServiceAccount $hostSid -PlanOnly 6>$null)
Assert ((($defaultCache | Where-Object Id -eq 'PeCacheDir').Data.Path) -like '*OtOpcUa*script-pe-cache') 'default PeCacheDir is under ProgramData\OtOpcUa'
if ($IsWindows) {
Write-Host 'SDDL parses on Windows'
$ds = [System.Security.AccessControl.DirectorySecurity]::new()
$ds.SetSecurityDescriptorSddlForm($sddl)
Assert ($ds.AreAccessRulesProtected) 'parsed PeCacheDir descriptor is protected'
foreach ($ace in $pe.Ace, $wf.Ace, $deny.Ace) {
$one = [System.Security.AccessControl.DirectorySecurity]::new()
$one.SetSecurityDescriptorSddlForm("D:$ace")
Assert (@($one.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])).Count -eq 1) "ACE $ace parses"
}
}
Write-Host ''
Write-Host "$($script:passes) passed, $($script:failures) failed"
if ($script:failures -gt 0) { exit 1 }
@@ -165,6 +165,13 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
_scriptRoot = scriptRoot ?? _log;
LoadTimeout = loadTimeout;
WorkerPath = ResolveWorkerPath(_worker);
// Fail closed (startup also refuses it, ScriptRunnerSelection.Validate): a configured identity that cannot be
// applied must never silently fall back to the host's own token.
if (_worker.ValidateIdentity(OperatingSystem.IsWindows()) is { } identityViolation)
throw new InvalidOperationException($"Scripting misconfiguration: {identityViolation}");
WorkerSid = string.IsNullOrWhiteSpace(_worker.Identity)
? null
: ScriptWorkerIdentity.TryResolveSid(_worker.Identity, out var sid, out _) ? sid : null;
// The PE cache is best effort: a directory or key that cannot be used is a Warning and a cache-less runner,
// never a startup failure (the path RULE is enforced at startup by ScriptRunnerSelection.Validate).
@@ -211,6 +218,9 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
/// <summary>The worker entry assembly this runner launches.</summary>
public string WorkerPath { get; }
/// <summary>The worker identity's SID (<c>Scripting:Worker:Identity</c>), or null when workers keep the host's token.</summary>
public string? WorkerSid { get; }
/// <summary>The overall state (the readiness check, the state gauge).</summary>
public ScriptRunnerState State => ComputeState(_shards.Select(s => s.DownFor).ToArray(), _isolation.DownFor, _worker.CircuitOpenAfter);
@@ -693,7 +703,8 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
PeCacheKey: _peCacheKey,
MarkerDirectory: _markerDirectory,
LoadTimeout: LoadTimeout,
CompileThreads: _compileThreads);
CompileThreads: _compileThreads,
WorkerSid: WorkerSid);
return new WorkerShard(label, isolation,
(onExiting, ct) => WorkerConnection.StartAsync(spec, _log, OnLogs, onExiting, ct),
OnWorkerExit, _log);
@@ -0,0 +1,506 @@
using System.ComponentModel;
using System.Diagnostics;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
/// <summary>
/// Starts a ScriptWorker under a restricted token for <c>Scripting:Worker:Identity</c> (lmxopcua#780 design §6, owner
/// decision §14 #5; see <see cref="ScriptWorkerIdentity"/> for why a restricted token and not a logon). The launch:
/// <list type="number">
/// <item>derives a primary token from the host's with <c>DISABLE_MAX_PRIVILEGE</c> and the restricting SIDs
/// (<see cref="ScriptWorkerIdentity.RestrictingSids"/>) — no password, no <c>SeAssignPrimaryTokenPrivilege</c>
/// (a restricted copy of the caller's own token does not need it);</item>
/// <item>sets the token's default DACL, and the token object's own DACL, to the host account, SYSTEM, the worker
/// identity and <c>RESTRICTED</c>, so the worker can open its own token and process, the worker's
/// process object stays openable by the host (kill, exit code, the Job Object) and objects the worker creates are
/// reachable by both;</item>
/// <item>creates the process <b>suspended</b> with <c>CreateProcessAsUserW</c>, inheriting exactly its stdout and
/// stderr pipe ends (<c>PROC_THREAD_ATTRIBUTE_HANDLE_LIST</c>), with the allow-listed environment block;</item>
/// <item>assigns it to the worker's Job Object <b>before</b> its first instruction runs, then resumes it.</item>
/// </list>
/// </summary>
[SupportedOSPlatform("windows")]
internal static class RestrictedWorkerLauncher
{
/// <summary>A started worker: its process, its job and the two stdio readers.</summary>
internal sealed class Launched : IDisposable
{
private readonly AnonymousPipeServerStream _stdout;
private readonly AnonymousPipeServerStream _stderr;
internal Launched(Process process, WindowsJobObject job, AnonymousPipeServerStream stdout, AnonymousPipeServerStream stderr)
{
Process = process;
Job = job;
_stdout = stdout;
_stderr = stderr;
}
/// <summary>The worker process (opened by pid while the creation handle was still held, so it cannot be a reused pid).</summary>
public Process Process { get; }
/// <summary>The worker's Job Object (kill on close).</summary>
public WindowsJobObject Job { get; }
/// <summary>Closes the stdio readers (the process and job are owned by the caller).</summary>
public void Dispose()
{
_stdout.Dispose();
_stderr.Dispose();
}
}
/// <summary>Starts <paramref name="psi"/> under a restricted token for <paramref name="workerSid"/>.</summary>
/// <param name="psi">What to run: file name, argument list, working directory and the final environment.</param>
/// <param name="workerSid">The worker identity's SID string.</param>
/// <param name="processMemoryLimitBytes">The Job Object's per-process commit limit.</param>
/// <param name="onStdout">Receives each stdout line.</param>
/// <param name="onStderr">Receives each stderr line.</param>
/// <returns>The started worker.</returns>
public static Launched Start(ProcessStartInfo psi, string workerSid, long processMemoryLimitBytes, Action<string> onStdout,
Action<string> onStderr)
{
ArgumentNullException.ThrowIfNull(psi);
GrantSessionObjects(workerSid);
using var token = CreateWorkerToken(workerSid);
var stdout = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
var stderr = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
WindowsJobObject? job = null;
PROCESS_INFORMATION pi = default;
var attributeList = IntPtr.Zero;
var handleList = IntPtr.Zero;
var environment = IntPtr.Zero;
try
{
var inherit = new[] { stdout.ClientSafePipeHandle.DangerousGetHandle(), stderr.ClientSafePipeHandle.DangerousGetHandle() };
handleList = Marshal.AllocHGlobal(IntPtr.Size * inherit.Length);
Marshal.Copy(inherit, 0, handleList, inherit.Length);
var size = IntPtr.Zero;
InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size);
attributeList = Marshal.AllocHGlobal(size);
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref size))
throw new Win32Exception(Marshal.GetLastWin32Error(), "InitializeProcThreadAttributeList failed");
if (!UpdateProcThreadAttribute(attributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handleList,
(IntPtr)(IntPtr.Size * inherit.Length), IntPtr.Zero, IntPtr.Zero))
throw new Win32Exception(Marshal.GetLastWin32Error(), "UpdateProcThreadAttribute(HANDLE_LIST) failed");
var si = new STARTUPINFOEX
{
StartupInfo = new STARTUPINFO
{
cb = Marshal.SizeOf<STARTUPINFOEX>(),
dwFlags = STARTF_USESTDHANDLES,
hStdInput = IntPtr.Zero,
hStdOutput = inherit[0],
hStdError = inherit[1],
},
lpAttributeList = attributeList,
};
var commandLine = new StringBuilder(WindowsProcessText.BuildCommandLine(psi.FileName, psi.ArgumentList));
environment = Marshal.StringToHGlobalUni(WindowsProcessText.BuildEnvironmentBlock(psi.Environment));
const uint flags = CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT;
if (!CreateProcessAsUserW(token, psi.FileName, commandLine, IntPtr.Zero, IntPtr.Zero, bInheritHandles: true, flags,
environment, string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory, ref si, out pi))
throw new Win32Exception(Marshal.GetLastWin32Error(), $"CreateProcessAsUser for '{psi.FileName}' failed");
// The child owns its ends now.
stdout.DisposeLocalCopyOfClientHandle();
stderr.DisposeLocalCopyOfClientHandle();
// Into the job before it runs a single instruction: the kill-on-close and process-count limits hold from birth.
job = WindowsJobObject.Assign(pi.hProcess, processMemoryLimitBytes);
// Opened while the creation handle is still held, so the pid cannot have been reused; .Handle caches a handle
// the Process object uses from here on (the token's default DACL grants the host account).
var process = Process.GetProcessById(pi.dwProcessId);
_ = process.Handle;
process.EnableRaisingEvents = true;
if (ResumeThread(pi.hThread) == uint.MaxValue)
{
var error = Marshal.GetLastWin32Error();
process.Dispose();
throw new Win32Exception(error, "ResumeThread failed");
}
Pump(stdout, onStdout);
Pump(stderr, onStderr);
return new Launched(process, job, stdout, stderr);
}
catch
{
if (pi.hProcess != IntPtr.Zero) TerminateProcess(pi.hProcess, 1);
job?.Dispose();
stdout.Dispose();
stderr.Dispose();
throw;
}
finally
{
if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
if (attributeList != IntPtr.Zero)
{
DeleteProcThreadAttributeList(attributeList);
Marshal.FreeHGlobal(attributeList);
}
if (handleList != IntPtr.Zero) Marshal.FreeHGlobal(handleList);
if (environment != IntPtr.Zero) Marshal.FreeHGlobal(environment);
}
}
/// <summary>
/// The worker's primary token: the host's, with every privilege but <c>SeChangeNotifyPrivilege</c> removed, the
/// restricting SIDs of <paramref name="workerSid"/>, and a default DACL of host account + SYSTEM + worker identity.
/// </summary>
/// <param name="workerSid">The worker identity's SID string.</param>
/// <returns>The token (owned by the caller).</returns>
internal static SafeAccessTokenHandle CreateWorkerToken(string workerSid)
{
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT | READ_CONTROL | WRITE_DAC,
out var processToken))
throw new Win32Exception(Marshal.GetLastWin32Error(), "OpenProcessToken failed");
using (processToken)
{
// Not the logon SID: the host's own process DACL grants it PROCESS_VM_READ (measured on windev), so a worker
// holding it could read the host's memory.
var restricting = ScriptWorkerIdentity.RestrictingSids(workerSid).Select(s => new SecurityIdentifier(s)).ToArray();
var pinned = new List<GCHandle>();
var entries = IntPtr.Zero;
try
{
var entrySize = Marshal.SizeOf<SID_AND_ATTRIBUTES>();
entries = Marshal.AllocHGlobal(entrySize * restricting.Length);
for (var i = 0; i < restricting.Length; i++)
{
var bytes = new byte[restricting[i].BinaryLength];
restricting[i].GetBinaryForm(bytes, 0);
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
pinned.Add(handle);
Marshal.StructureToPtr(new SID_AND_ATTRIBUTES { Sid = handle.AddrOfPinnedObject(), Attributes = 0 },
entries + (i * entrySize), fDeleteOld: false);
}
if (!CreateRestrictedToken(processToken, DISABLE_MAX_PRIVILEGE, 0, IntPtr.Zero, 0, IntPtr.Zero,
(uint)restricting.Length, entries, out var restricted))
throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateRestrictedToken failed");
try
{
SetDefaultDacl(restricted, new SecurityIdentifier(workerSid));
return restricted;
}
catch
{
restricted.Dispose();
throw;
}
}
finally
{
foreach (var handle in pinned) handle.Free();
if (entries != IntPtr.Zero) Marshal.FreeHGlobal(entries);
}
}
}
private static readonly object SessionGrantLock = new();
private static readonly HashSet<string> SessionGranted = new(StringComparer.Ordinal);
/// <summary>
/// Adds the worker identity to the DACL of the host's window station and desktop (once per process and identity).
/// USER32's DllMain connects the process to them, and the runtime imports USER32, so a worker whose restricting SIDs
/// have no access there fails to load <c>coreclr.dll</c> (<c>ERROR_DLL_INIT_FAILED</c>, measured on windev). The
/// logon SID, which those objects normally grant, is deliberately NOT a restricting SID (the host's process DACL
/// gives it <c>PROCESS_VM_READ</c>). The grant is to the identity only and lives on the session's kernel objects:
/// it ends with the host's session and changes nothing persistent.
/// </summary>
/// <param name="workerSid">The worker identity's SID string.</param>
internal static void GrantSessionObjects(string workerSid)
{
lock (SessionGrantLock)
{
if (!SessionGranted.Add(workerSid)) return;
try
{
var sid = new SecurityIdentifier(workerSid);
AddAllowAce(GetProcessWindowStation(), sid, WindowStationRights);
AddAllowAce(GetThreadDesktop(GetCurrentThreadId()), sid, DesktopRights);
}
catch
{
SessionGranted.Remove(workerSid);
throw;
}
}
}
/// <summary>The rights the worker identity gets on the window station: enumerate, read attributes, global atoms.</summary>
internal const int WindowStationRights = 0x0001 | 0x0002 | 0x0020 | 0x0100 | 0x00020000;
/// <summary>The rights the worker identity gets on the desktop: read objects, create windows (USER32 init), enumerate.</summary>
internal const int DesktopRights = 0x0001 | 0x0002 | 0x0040 | 0x00020000;
private static void AddAllowAce(IntPtr handle, SecurityIdentifier sid, int mask)
{
if (handle == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "no window station / desktop handle");
var error = GetSecurityInfoRaw(handle, SE_WINDOW_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero, out var daclPtr,
IntPtr.Zero, out var sd);
if (error != 0) throw new Win32Exception((int)error, "GetSecurityInfo(window object) failed");
try
{
RawAcl acl;
if (daclPtr == IntPtr.Zero) return; // a NULL DACL already admits everyone
var size = Marshal.ReadInt16(daclPtr + 2);
var bytes = new byte[size];
Marshal.Copy(daclPtr, bytes, 0, size);
acl = new RawAcl(bytes, 0);
foreach (var ace in acl.OfType<CommonAce>())
if (ace.SecurityIdentifier == sid && ace.AceQualifier == AceQualifier.AccessAllowed && (ace.AccessMask & mask) == mask) return;
acl.InsertAce(acl.Count, new CommonAce(AceFlags.None, AceQualifier.AccessAllowed, mask, sid, false, null));
var updated = new byte[acl.BinaryLength];
acl.GetBinaryForm(updated, 0);
var pinned = GCHandle.Alloc(updated, GCHandleType.Pinned);
try
{
error = SetSecurityInfoRaw(handle, SE_WINDOW_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero,
pinned.AddrOfPinnedObject(), IntPtr.Zero);
if (error != 0) throw new Win32Exception((int)error, "SetSecurityInfo(window object) failed");
}
finally
{
pinned.Free();
}
}
finally
{
LocalFree(sd);
}
}
/// <summary>The SDDL of the default DACL a worker token carries (tests).</summary>
/// <param name="hostUser">The host account.</param>
/// <param name="worker">The worker identity.</param>
/// <returns>The raw ACL.</returns>
internal static RawAcl DefaultDacl(SecurityIdentifier hostUser, SecurityIdentifier worker)
{
var acl = new RawAcl(GenericAcl.AclRevision, 3);
var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
var restricted = new SecurityIdentifier(ScriptWorkerIdentity.RestrictedSid);
foreach (var sid in new[] { hostUser, system, worker, restricted }.Distinct())
acl.InsertAce(acl.Count, new CommonAce(AceFlags.None, AceQualifier.AccessAllowed, GENERIC_ALL, sid, false, null));
return acl;
}
private static void SetDefaultDacl(SafeAccessTokenHandle token, SecurityIdentifier worker)
{
using var identity = WindowsIdentity.GetCurrent();
var acl = DefaultDacl(identity.User ?? throw new InvalidOperationException("the host token has no user"), worker);
var bytes = new byte[acl.BinaryLength];
acl.GetBinaryForm(bytes, 0);
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try
{
var info = new TOKEN_DEFAULT_DACL { DefaultDacl = handle.AddrOfPinnedObject() };
if (!SetTokenInformation(token, TokenDefaultDacl, ref info, (uint)Marshal.SizeOf<TOKEN_DEFAULT_DACL>()))
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetTokenInformation(TokenDefaultDacl) failed");
// The token object itself: its DACL came from the host's default DACL, which no restricting SID passes, so the
// worker could not open its own token and the loader fails (coreclr.dll, ERROR_DLL_INIT_FAILED, on windev).
var error = SetSecurityInfo(token, SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero,
handle.AddrOfPinnedObject(), IntPtr.Zero);
if (error != 0) throw new Win32Exception((int)error, "SetSecurityInfo(token DACL) failed");
}
finally
{
handle.Free();
}
}
/// <summary>
/// Gives the worker identity Modify on its per-launch working directory: protected, host account + SYSTEM full
/// control, worker identity modify. Its temp files go there (<c>TEMP</c>/<c>TMP</c> point at it).
/// </summary>
/// <param name="directory">The working directory the host created.</param>
/// <param name="workerSid">The worker identity's SID string.</param>
public static void GrantWorkDirectory(string directory, string workerSid)
{
using var identity = WindowsIdentity.GetCurrent();
var security = new DirectorySecurity();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
const InheritanceFlags inherit = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
var principals = new List<SecurityIdentifier> { new(WellKnownSidType.LocalSystemSid, null) };
if (identity.User is { } user) principals.Add(user);
if (identity.Owner is { } owner && !principals.Contains(owner)) principals.Add(owner);
foreach (var principal in principals)
security.AddAccessRule(new FileSystemAccessRule(principal, FileSystemRights.FullControl, inherit, PropagationFlags.None,
AccessControlType.Allow));
security.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(workerSid), FileSystemRights.Modify | FileSystemRights.Synchronize,
inherit, PropagationFlags.None, AccessControlType.Allow));
new DirectoryInfo(directory).SetAccessControl(security);
}
private static void Pump(AnonymousPipeServerStream stream, Action<string> onLine)
{
var thread = new Thread(() =>
{
try
{
using var reader = new StreamReader(stream, Encoding.UTF8);
while (reader.ReadLine() is { } line) onLine(line);
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// The worker is gone or the host is stopping.
}
})
{ IsBackground = true, Name = "ScriptWorker.Stdio" };
thread.Start();
}
private const uint TOKEN_DUPLICATE = 0x0002;
private const uint TOKEN_ASSIGN_PRIMARY = 0x0001;
private const uint TOKEN_QUERY = 0x0008;
private const uint TOKEN_ADJUST_DEFAULT = 0x0080;
private const uint READ_CONTROL = 0x00020000;
private const uint WRITE_DAC = 0x00040000; // the restricted token inherits these rights: its own DACL is rewritten
private const uint DISABLE_MAX_PRIVILEGE = 0x1;
private const int TokenDefaultDacl = 6;
private const int SE_KERNEL_OBJECT = 6;
private const int SE_WINDOW_OBJECT = 7;
private const uint DACL_SECURITY_INFORMATION = 0x4;
private const int GENERIC_ALL = 0x10000000;
private const int STARTF_USESTDHANDLES = 0x00000100;
private const uint CREATE_SUSPENDED = 0x00000004;
private const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
private const uint CREATE_NO_WINDOW = 0x08000000;
private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
private static readonly IntPtr PROC_THREAD_ATTRIBUTE_HANDLE_LIST = (IntPtr)0x00020002;
[StructLayout(LayoutKind.Sequential)]
private struct SID_AND_ATTRIBUTES
{
public IntPtr Sid;
public uint Attributes;
}
[StructLayout(LayoutKind.Sequential)]
private struct TOKEN_DEFAULT_DACL
{
public IntPtr DefaultDacl;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct STARTUPINFO
{
public int cb;
public string? lpReserved;
public string? lpDesktop;
public string? lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFOEX
{
public STARTUPINFO StartupInfo;
public IntPtr lpAttributeList;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
#pragma warning disable SYSLIB1054 // DllImport kept, as in WindowsJobObject: LibraryImport would need AllowUnsafeBlocks.
[DllImport("kernel32.dll")]
private static extern IntPtr GetCurrentProcess();
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool OpenProcessToken(IntPtr process, uint desiredAccess, out SafeAccessTokenHandle token);
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateRestrictedToken(SafeAccessTokenHandle existingToken, uint flags, uint disableSidCount,
IntPtr sidsToDisable, uint deletePrivilegeCount, IntPtr privilegesToDelete, uint restrictedSidCount, IntPtr sidsToRestrict,
out SafeAccessTokenHandle newToken);
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetTokenInformation(SafeAccessTokenHandle token, int infoClass, ref TOKEN_DEFAULT_DACL info, uint length);
[DllImport("advapi32.dll", SetLastError = true)]
private static extern uint SetSecurityInfo(SafeAccessTokenHandle handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
IntPtr dacl, IntPtr sacl);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, IntPtr value, IntPtr size,
IntPtr previousValue, IntPtr returnSize);
[DllImport("kernel32.dll")]
private static extern void DeleteProcThreadAttributeList(IntPtr list);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessAsUserW(SafeAccessTokenHandle token, string applicationName, StringBuilder commandLine,
IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandles, uint creationFlags,
IntPtr environment, string? currentDirectory, ref STARTUPINFOEX startupInfo, out PROCESS_INFORMATION processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint ResumeThread(IntPtr thread);
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr GetProcessWindowStation();
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr GetThreadDesktop(uint threadId);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
[DllImport("kernel32.dll")]
private static extern IntPtr LocalFree(IntPtr memory);
[DllImport("advapi32.dll", EntryPoint = "GetSecurityInfo", SetLastError = true)]
private static extern uint GetSecurityInfoRaw(IntPtr handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
out IntPtr dacl, IntPtr sacl, out IntPtr securityDescriptor);
[DllImport("advapi32.dll", EntryPoint = "SetSecurityInfo", SetLastError = true)]
private static extern uint SetSecurityInfoRaw(IntPtr handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
IntPtr dacl, IntPtr sacl);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool TerminateProcess(IntPtr process, uint exitCode);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
#pragma warning restore SYSLIB1054
}
@@ -29,6 +29,16 @@ internal sealed class WindowsJobObject : IDisposable
/// <param name="processMemoryLimitBytes">The per-process commit limit.</param>
/// <returns>The job; dispose it to kill the worker.</returns>
public static WindowsJobObject Assign(Process process, long processMemoryLimitBytes)
=> Assign(process.Handle, processMemoryLimitBytes);
/// <summary>
/// Creates a job with the worker limits and assigns the process behind <paramref name="processHandle"/> to it — the
/// restricted launch assigns its worker while it is still suspended (<see cref="RestrictedWorkerLauncher"/>).
/// </summary>
/// <param name="processHandle">A handle with <c>PROCESS_SET_QUOTA</c> and <c>PROCESS_TERMINATE</c>.</param>
/// <param name="processMemoryLimitBytes">The per-process commit limit.</param>
/// <returns>The job; dispose it to kill the worker.</returns>
public static WindowsJobObject Assign(IntPtr processHandle, long processMemoryLimitBytes)
{
var handle = CreateJobObjectW(IntPtr.Zero, null);
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed");
@@ -48,7 +58,7 @@ internal sealed class WindowsJobObject : IDisposable
if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, ref info,
(uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>()))
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed");
if (!AssignProcessToJobObject(handle, process.Handle))
if (!AssignProcessToJobObject(handle, processHandle))
throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
return job;
}
@@ -0,0 +1,64 @@
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
/// <summary>
/// The text a native Windows process launch takes (<see cref="RestrictedWorkerLauncher"/>): the command line and the
/// environment block. Pure string work, so it is testable on any OS.
/// </summary>
internal static class WindowsProcessText
{
/// <summary>A Windows command line for <paramref name="fileName"/> and <paramref name="arguments"/> (MSVC quoting rules).</summary>
/// <param name="fileName">The executable.</param>
/// <param name="arguments">The arguments.</param>
/// <returns>The command line.</returns>
internal static string BuildCommandLine(string fileName, IEnumerable<string> arguments)
{
var sb = new StringBuilder();
AppendQuoted(sb, fileName);
foreach (var argument in arguments)
{
sb.Append(' ');
AppendQuoted(sb, argument);
}
return sb.ToString();
}
private static void AppendQuoted(StringBuilder sb, string argument)
{
if (argument.Length > 0 && argument.IndexOfAny([' ', '\t', '\n', '\v', '"']) < 0)
{
sb.Append(argument);
return;
}
sb.Append('"');
var backslashes = 0;
foreach (var c in argument)
{
if (c == '\\')
{
backslashes++;
continue;
}
if (c == '"') sb.Append('\\', (backslashes * 2) + 1);
else sb.Append('\\', backslashes);
backslashes = 0;
sb.Append(c);
}
sb.Append('\\', backslashes * 2);
sb.Append('"');
}
/// <summary>A sorted, double-NUL-terminated UTF-16 environment block.</summary>
/// <param name="environment">The variables.</param>
/// <returns>The block.</returns>
internal static string BuildEnvironmentBlock(IDictionary<string, string?> environment)
{
var sb = new StringBuilder();
foreach (var (name, value) in environment.Where(kv => kv.Value is not null).OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
sb.Append(name).Append('=').Append(value).Append('\0');
if (sb.Length == 0) sb.Append('\0');
sb.Append('\0');
return sb.ToString();
}
}
@@ -77,6 +77,10 @@ internal sealed record BatchSubmission(string Key, EvalRequest Request, TimeSpan
/// it, lmxopcua#790), before the worker is killed and the key struck; null = 60 s.
/// </param>
/// <param name="CompileThreads">The worker's dedicated compile threads; 0 = its default.</param>
/// <param name="WorkerSid">
/// Windows only: the worker identity's SID (<c>Scripting:Worker:Identity</c>). Set, the worker starts under a restricted
/// token (<see cref="RestrictedWorkerLauncher"/>) and the pipe ACL admits it beside the host; null keeps the host's token.
/// </param>
internal sealed record WorkerLaunchSpec(
string Shard,
string WorkerPath,
@@ -89,7 +93,8 @@ internal sealed record WorkerLaunchSpec(
byte[]? PeCacheKey = null,
string? MarkerDirectory = null,
TimeSpan? LoadTimeout = null,
int CompileThreads = 0)
int CompileThreads = 0,
string? WorkerSid = null)
{
/// <summary>The default compile timeout.</summary>
public static readonly TimeSpan DefaultLoadTimeout = TimeSpan.FromSeconds(60);
@@ -119,6 +124,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
private readonly Process _process;
private readonly NamedPipeServerStream _pipe;
private readonly IDisposable? _job;
private readonly IDisposable? _stdio;
private readonly string _workDir;
private readonly ILogger _log;
private readonly Action<LogBatch> _onLogs;
@@ -143,10 +149,11 @@ internal sealed class WorkerConnection : IAsyncDisposable
private long _nextLoadId;
private int _disposed;
private WorkerConnection(string shard, Process process, NamedPipeServerStream pipe, IDisposable? job, string workDir,
ILogger log, Action<LogBatch> onLogs, TimeSpan heartbeatInterval, TimeSpan loadTimeout, Action<WorkerExit> onExiting,
string? markerPath)
private WorkerConnection(string shard, Process process, NamedPipeServerStream pipe, IDisposable? job, IDisposable? stdio,
string workDir, ILogger log, Action<LogBatch> onLogs, TimeSpan heartbeatInterval, TimeSpan loadTimeout,
Action<WorkerExit> onExiting, string? markerPath)
{
_stdio = stdio;
_loadTimeout = loadTimeout;
_onExiting = onExiting;
_markerPath = markerPath;
@@ -222,36 +229,57 @@ internal sealed class WorkerConnection : IAsyncDisposable
var workDir = Path.Combine(Path.GetTempPath(), "otopcua-scriptworker", pipeName);
Directory.CreateDirectory(workDir);
// One instance, current user only: the pipe accepts exactly one connection, from this account.
var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
// One instance, admitted principals only: the pipe accepts exactly one connection. Windows: an explicit,
// protected ACL — the host account and, under Scripting:Worker:Identity, the worker identity (design §6).
// Elsewhere: CurrentUserOnly (a Unix socket owned by this user, 0600).
if (spec.WorkerSid is not null && !OperatingSystem.IsWindows())
throw new PlatformNotSupportedException("Scripting:Worker:Identity is only supported on Windows");
var pipe = OperatingSystem.IsWindows()
? WorkerPipeSecurity.CreateServer(pipeName, WorkerPipeSecurity.ForCurrentHost(spec.WorkerSid))
: new NamedPipeServerStream(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
Process? process = null;
IDisposable? job = null;
IDisposable? stdio = null;
string? markerPath = null;
try
{
var psi = BuildStartInfo(spec, pipeName, nonce, workDir);
process = new Process { StartInfo = psi, EnableRaisingEvents = true };
if (!process.Start()) throw new InvalidOperationException("the worker process did not start");
// Before anything else can fail: a host that dies from here on leaves a marker the next start's orphan
// sweep can match to exactly this process (design §3.3).
if (spec.MarkerDirectory is { } markerDir) markerPath = WorkerLaunchMarker.Write(markerDir, process, pipeName, log);
if (OperatingSystem.IsWindows())
{
// Native and JIT memory backstop above the managed heap limit.
job = WindowsJobObject.Assign(process, spec.HeapLimitBytes + 256L * 1024 * 1024);
}
var shardLabel = spec.Shard;
process.ErrorDataReceived += (_, e) =>
void OnStderr(string? line)
{
if (e.Data is { Length: > 0 } line) log.Warning("ScriptWorker[{Shard}] {WorkerStderr}", shardLabel, line);
};
process.OutputDataReceived += (_, e) =>
if (line is { Length: > 0 }) log.Warning("ScriptWorker[{Shard}] {WorkerStderr}", shardLabel, line);
}
void OnStdout(string? line)
{
if (e.Data is { Length: > 0 } line) log.Debug("ScriptWorker[{Shard}] stdout {WorkerStdout}", shardLabel, line);
};
process.BeginErrorReadLine();
process.BeginOutputReadLine();
if (line is { Length: > 0 }) log.Debug("ScriptWorker[{Shard}] stdout {WorkerStdout}", shardLabel, line);
}
// Native and JIT memory backstop above the managed heap limit.
var jobLimit = spec.HeapLimitBytes + 256L * 1024 * 1024;
if (spec.WorkerSid is { } workerSid && OperatingSystem.IsWindows())
{
// The worker reaches only what its identity may: its working directory is the one place it writes.
RestrictedWorkerLauncher.GrantWorkDirectory(workDir, workerSid);
var launched = RestrictedWorkerLauncher.Start(psi, workerSid, jobLimit, OnStdout, OnStderr);
process = launched.Process;
job = launched.Job;
stdio = launched;
if (spec.MarkerDirectory is { } restrictedMarkerDir)
markerPath = WorkerLaunchMarker.Write(restrictedMarkerDir, process, pipeName, log);
}
else
{
process = new Process { StartInfo = psi, EnableRaisingEvents = true };
if (!process.Start()) throw new InvalidOperationException("the worker process did not start");
// Before anything else can fail: a host that dies from here on leaves a marker the next start's orphan
// sweep can match to exactly this process (design §3.3).
if (spec.MarkerDirectory is { } markerDir) markerPath = WorkerLaunchMarker.Write(markerDir, process, pipeName, log);
if (OperatingSystem.IsWindows()) job = WindowsJobObject.Assign(process, jobLimit);
process.ErrorDataReceived += (_, e) => OnStderr(e.Data);
process.OutputDataReceived += (_, e) => OnStdout(e.Data);
process.BeginErrorReadLine();
process.BeginOutputReadLine();
}
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
handshakeCts.CancelAfter(spec.HandshakeTimeout);
@@ -283,7 +311,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
else
await HandshakeAsync(pipe, hello, nonce, process.Id, handshakeCts.Token).ConfigureAwait(false);
var connection = new WorkerConnection(spec.Shard, process, pipe, job, workDir, log, onLogs, spec.HeartbeatInterval,
var connection = new WorkerConnection(spec.Shard, process, pipe, job, stdio, workDir, log, onLogs, spec.HeartbeatInterval,
spec.EffectiveLoadTimeout, onExiting, markerPath);
connection.Begin();
return connection;
@@ -292,6 +320,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
{
try { if (process is { HasExited: false }) process.Kill(entireProcessTree: true); } catch { /* best effort */ }
job?.Dispose();
stdio?.Dispose();
process?.Dispose();
await pipe.DisposeAsync().ConfigureAwait(false);
TryDeleteDirectory(workDir);
@@ -367,7 +396,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
return reply.Ready;
}
private static ProcessStartInfo BuildStartInfo(WorkerLaunchSpec spec, string pipeName, string nonce, string workDir)
internal static ProcessStartInfo BuildStartInfo(WorkerLaunchSpec spec, string pipeName, string nonce, string workDir)
{
var psi = new ProcessStartInfo
{
@@ -408,6 +437,15 @@ internal sealed class WorkerConnection : IAsyncDisposable
psi.Environment[ScriptWorkerEnvironment.Nonce] = nonce;
psi.Environment[ScriptWorkerEnvironment.ParentPid] = Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture);
psi.Environment[ScriptWorkerEnvironment.Shard] = spec.Shard;
if (spec.WorkerSid is not null)
{
// A restricted worker cannot write the host's temp directory; its own working directory is its temp.
psi.Environment["TEMP"] = workDir;
psi.Environment["TMP"] = workDir;
// No debugger / profiler / diagnostics IPC: a restricted token cannot create the runtime's named objects,
// and nothing should attach to a sandboxed worker anyway.
psi.Environment["DOTNET_EnableDiagnostics"] = "0";
}
return psi;
}
@@ -651,6 +689,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
_writer.Complete();
await _pipe.DisposeAsync().ConfigureAwait(false);
_job?.Dispose();
_stdio?.Dispose();
_process.Dispose();
TryDeleteDirectory(_workDir);
WorkerLaunchMarker.TryDelete(_markerPath);
@@ -0,0 +1,60 @@
using System.IO.Pipes;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
/// <summary>
/// The ScriptWorker pipe's explicit ACL on Windows (lmxopcua#780 design §6, "an explicit ACL when the worker runs as a
/// different account"): a protected DACL that admits the host account and — when <c>Scripting:Worker:Identity</c> is
/// set — the worker identity, each with read/write, and nobody else (no SYSTEM, no Administrators, no Everyone). The
/// owner is the host token's owner, as .NET's <c>PipeOptions.CurrentUserOnly</c> sets it, so the worker's
/// <c>CurrentUserOnly</c> client check (pipe owner == its token owner, which a restricted token keeps) still
/// authenticates the server end.
/// </summary>
[SupportedOSPlatform("windows")]
internal static class WorkerPipeSecurity
{
/// <summary>The rights each admitted principal gets: read/write data, attributes and the security descriptor (read).</summary>
internal const PipeAccessRights GrantedRights = PipeAccessRights.ReadWrite;
/// <summary>Builds the pipe ACL for this host process.</summary>
/// <param name="workerSid">The worker identity's SID string, or null when the worker keeps the host's token.</param>
/// <returns>The pipe security.</returns>
public static PipeSecurity ForCurrentHost(string? workerSid)
{
using var identity = WindowsIdentity.GetCurrent();
var owner = identity.Owner ?? identity.User ?? throw new InvalidOperationException("the host token has no owner");
var user = identity.User ?? owner;
return Create(owner, user, workerSid is null ? null : new SecurityIdentifier(workerSid));
}
/// <summary>Builds the pipe ACL.</summary>
/// <param name="owner">The pipe owner (the host token's owner).</param>
/// <param name="hostUser">The host account (its token's user SID).</param>
/// <param name="worker">The worker identity, or null.</param>
/// <returns>The pipe security: protected, allow-only, one ACE for the host account and one for the worker identity.</returns>
public static PipeSecurity Create(SecurityIdentifier owner, SecurityIdentifier hostUser, SecurityIdentifier? worker)
{
var security = new PipeSecurity();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
security.SetOwner(owner);
// The host account and the worker identity, nothing else: not the owner when it differs (an elevated host's
// owner is BUILTIN\Administrators — .NET's CurrentUserOnly would grant it; the owner keeps only its implicit
// READ_CONTROL / WRITE_DAC).
var principals = new List<SecurityIdentifier> { hostUser };
if (worker is not null && !principals.Contains(worker)) principals.Add(worker);
foreach (var principal in principals)
security.AddAccessRule(new PipeAccessRule(principal, GrantedRights, AccessControlType.Allow));
return security;
}
/// <summary>Creates the single-instance server end of a worker pipe with <paramref name="security"/>.</summary>
/// <param name="pipeName">The random pipe name.</param>
/// <param name="security">The ACL (<see cref="ForCurrentHost"/>).</param>
/// <returns>The server stream.</returns>
public static NamedPipeServerStream CreateServer(string pipeName, PipeSecurity security)
=> NamedPipeServerStreamAcl.Create(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous, inBufferSize: 0, outBufferSize: 0, security);
}
@@ -138,6 +138,17 @@ public sealed class ScriptWorkerOptions
/// </summary>
public string? ExecutablePath { get; set; }
/// <summary>
/// The worker's identity on Windows (design §6, owner decision §14 #5): <c>NT SERVICE\&lt;name&gt;</c> — the virtual
/// account <c>scripts/install/Install-ScriptWorkerIsolation.ps1</c> registers, by default
/// <c>NT SERVICE\OtOpcUaScriptWorker</c> — or a SID string. When set, each worker starts under a restricted token
/// derived from the host's (see <see cref="ScriptWorkerIdentity"/>): every privilege removed, and every access check
/// must ALSO pass for this identity, <c>Everyone</c>, <c>BUILTIN\Users</c> or <c>RESTRICTED</c>. The pipe ACL then
/// admits the host account and this identity only. Null (the default) keeps the host's own token. Windows only:
/// startup refuses it elsewhere.
/// </summary>
public string? Identity { get; set; }
/// <summary>The hard grace that applies to a run with soft budget <paramref name="softTimeout"/>.</summary>
/// <param name="softTimeout">The run's soft deadline <c>T</c>.</param>
/// <returns><see cref="HardGrace"/> when set, otherwise <c>max(1 s, T)</c>.</returns>
@@ -228,6 +239,7 @@ public sealed class ScriptWorkerOptions
IsolationProbationRuns = probation,
PeCacheDir = PeCacheDir,
ExecutablePath = ExecutablePath,
Identity = Identity,
};
}
@@ -252,6 +264,21 @@ public sealed class ScriptWorkerOptions
return $"{key} '{PeCacheDir}' is inside the content root '{contentRootPath}'; it must live outside it.";
return null;
}
/// <summary>
/// Fail-closed identity validation: <see cref="Identity"/>, when set, must be <c>NT SERVICE\&lt;name&gt;</c> or a SID
/// string, and the host must run on Windows.
/// </summary>
/// <param name="isWindows">Whether the host runs on Windows (callers pass <see cref="OperatingSystem.IsWindows"/>).</param>
/// <returns>The violation, or null when the identity is unset or acceptable.</returns>
public string? ValidateIdentity(bool isWindows)
{
if (string.IsNullOrWhiteSpace(Identity)) return null;
const string key = ScriptingOptions.SectionName + ":Worker:" + nameof(Identity);
if (!isWindows)
return $"{key} '{Identity}' is only supported on Windows (it is a restricted token); unset it on this host.";
return ScriptWorkerIdentity.TryResolveSid(Identity, out _, out var error) ? null : $"{key} '{Identity}': {error}";
}
}
/// <summary>
@@ -0,0 +1,123 @@
using System.Buffers.Binary;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// The ScriptWorker's Windows identity (<c>Scripting:Worker:Identity</c>, lmxopcua#780 design §6, owner decision §14 #5).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a restricted token and not a logon.</b> Windows starts a process under a virtual account
/// (<c>NT SERVICE\name</c>) only when the Service Control Manager starts that service; a host cannot launch K + 1
/// short-lived children, each with its own nonce, pipe and Job Object, that way. A separate local user would need
/// a stored password and <c>SeAssignPrimaryTokenPrivilege</c> on the host account. Instead the host derives a
/// <b>restricted token</b> from its own (the Windows Service Hardening / browser-sandbox technique): every
/// privilege is removed, and the virtual account's SID is one of the token's <i>restricting SIDs</i>, so every
/// access check must pass twice — once for the host account and once for the restricting set
/// (<see cref="RestrictingSids"/>). The worker can therefore reach only what BOTH the host account AND the worker
/// identity (or <c>Everyone</c> / <c>BUILTIN\Users</c> / <c>RESTRICTED</c>, which the runtime needs to load from
/// <c>System32</c>) may reach: in effect a standard user bounded by the host account, with no privileges.
/// </para>
/// <para>
/// The virtual account's SID is computable from its name (<see cref="ServiceSid"/>), so the identity works for ACLs
/// and the firewall rule whether or not the installer's anchor service exists; the anchor only makes the name
/// resolvable in ACL editors.
/// </para>
/// </remarks>
public static partial class ScriptWorkerIdentity
{
/// <summary>The installer's default virtual account name (the anchor service's name).</summary>
public const string DefaultServiceName = "OtOpcUaScriptWorker";
/// <summary>The installer's default identity, as configured.</summary>
public const string DefaultIdentity = @"NT SERVICE\" + DefaultServiceName;
/// <summary>Everyone.</summary>
public const string EveryoneSid = "S-1-1-0";
/// <summary>BUILTIN\Users.</summary>
public const string BuiltinUsersSid = "S-1-5-32-545";
/// <summary>NT AUTHORITY\RESTRICTED (the SID many system objects grant for restricted tokens).</summary>
public const string RestrictedSid = "S-1-5-12";
private const string ServicePrefix = @"NT SERVICE\";
/// <summary>
/// The restricting SIDs of a worker token for <paramref name="workerSid"/>: the identity itself, then
/// <c>Everyone</c>, <c>BUILTIN\Users</c> and <c>RESTRICTED</c>. <c>Authenticated Users</c> and the logon SID are
/// deliberately absent (the first is granted Modify on many data folders; the second reaches the host's own
/// processes in an interactive session).
/// </summary>
/// <param name="workerSid">The worker identity's SID string.</param>
/// <returns>The restricting SID strings, identity first.</returns>
public static IReadOnlyList<string> RestrictingSids(string workerSid) => [workerSid, EveryoneSid, BuiltinUsersSid, RestrictedSid];
/// <summary>
/// The SID Windows assigns the virtual account <c>NT SERVICE\<paramref name="serviceName"/></c>:
/// <c>S-1-5-80-</c> followed by the SHA-1 of the upper-cased name in UTF-16LE, read as five little-endian
/// 32-bit values (the algorithm behind <c>sc.exe showsid</c>).
/// </summary>
/// <param name="serviceName">The service name (no <c>NT SERVICE\</c> prefix).</param>
/// <returns>The SID string.</returns>
public static string ServiceSid(string serviceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
var hash = SHA1.HashData(Encoding.Unicode.GetBytes(serviceName.ToUpperInvariant()));
var sb = new StringBuilder("S-1-5-80");
for (var i = 0; i < 5; i++)
sb.Append('-').Append(BinaryPrimitives.ReadUInt32LittleEndian(hash.AsSpan(i * 4, 4)).ToString(CultureInfo.InvariantCulture));
return sb.ToString();
}
/// <summary>Resolves a configured identity to a SID string without touching the OS.</summary>
/// <param name="identity"><c>NT SERVICE\name</c> or a SID string (<c>S-1-…</c>).</param>
/// <param name="sid">The SID string.</param>
/// <param name="error">Why it was refused.</param>
/// <returns>True when resolved.</returns>
public static bool TryResolveSid(string? identity, out string sid, out string? error)
{
sid = string.Empty;
error = null;
var value = identity?.Trim();
if (string.IsNullOrEmpty(value))
{
error = "no identity is set";
return false;
}
if (value.StartsWith(ServicePrefix, StringComparison.OrdinalIgnoreCase))
{
var name = value[ServicePrefix.Length..];
if (!ServiceNamePattern().IsMatch(name))
{
error = "the virtual account name must be 1–80 letters, digits, '_', '-' or '.'";
return false;
}
sid = ServiceSid(name);
return true;
}
if (SidPattern().IsMatch(value))
{
if (value is EveryoneSid or BuiltinUsersSid or RestrictedSid || value.StartsWith("S-1-5-32-", StringComparison.Ordinal)
|| value == "S-1-5-18" || value == "S-1-5-11")
{
error = "a well-known group or SYSTEM is not a worker identity; use a dedicated virtual account";
return false;
}
sid = value.ToUpperInvariant();
return true;
}
error = @"expected 'NT SERVICE\<name>' (the installer's virtual account) or a SID string 'S-1-…'";
return false;
}
[GeneratedRegex(@"^[A-Za-z0-9_.\-]{1,80}$", RegexOptions.CultureInvariant)]
private static partial Regex ServiceNamePattern();
[GeneratedRegex(@"^[Ss]-1-\d+(-\d+){1,14}$", RegexOptions.CultureInvariant)]
private static partial Regex SidPattern();
}
@@ -68,7 +68,8 @@ public sealed class ScriptRunnerSelection : IScriptRunnerProvider, IAsyncDisposa
/// <summary>
/// Startup checks for the runner options: returns the Warnings to log, and throws when the configuration must
/// not start (a <c>PeCacheDir</c> that is relative or inside the content root — fail closed).
/// not start (a <c>PeCacheDir</c> that is relative or inside the content root, or a <c>Worker:Identity</c> that
/// is malformed or set off Windows — fail closed).
/// </summary>
/// <param name="options">The bound, sanitized <c>Scripting</c> options.</param>
/// <param name="contentRootPath">The host's content root.</param>
@@ -82,6 +83,8 @@ public sealed class ScriptRunnerSelection : IScriptRunnerProvider, IAsyncDisposa
if (options.Worker.ValidatePaths(contentRootPath) is { } violation)
throw new InvalidOperationException($"Scripting misconfiguration: {violation}");
if (options.Worker.ValidateIdentity(OperatingSystem.IsWindows()) is { } identityViolation)
throw new InvalidOperationException($"Scripting misconfiguration: {identityViolation}");
var mode = $"Scripting:Runner={options.Runner}";
var shadow = options.Runner == ScriptRunnerMode.Shadow;
@@ -536,6 +536,13 @@ internal sealed class WorkerHost
{
return false;
}
catch (System.ComponentModel.Win32Exception)
{
// Access denied: a worker under Scripting:Worker:Identity runs with a restricted token that may not open the
// host's process. It exists (GetProcessById found it), so it is alive; on Windows the Job Object's
// kill-on-close is what ends the worker with its host, and pipe end-of-stream backs it up.
return true;
}
}
private static void OnWriteFault(Exception ex)
@@ -0,0 +1,76 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// <c>Scripting:Worker:Identity</c> (lmxopcua#780 design §6, §14 #5): the virtual account's SID is computed exactly as
/// Windows computes it, so the host's restricted token, the installer's ACLs (which compute the same SID in
/// PowerShell, <c>scripts/install/tests/Test-ScriptWorkerIsolation.ps1</c> pins the same value) and the pipe ACL agree.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptWorkerIdentityTests
{
/// <summary>The installer's default identity; the PowerShell harness pins the same string.</summary>
private const string OtOpcUaScriptWorkerSid = "S-1-5-80-1846135827-3903057317-1998105452-3938448374-3023753135";
[Fact]
public void The_service_sid_algorithm_reproduces_a_well_known_virtual_account()
=> ScriptWorkerIdentity.ServiceSid("TrustedInstaller")
.ShouldBe("S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464");
[Fact]
public void The_default_identity_resolves_to_the_pinned_sid_case_insensitively()
{
ScriptWorkerIdentity.TryResolveSid(ScriptWorkerIdentity.DefaultIdentity, out var sid, out var error).ShouldBeTrue(error);
sid.ShouldBe(OtOpcUaScriptWorkerSid);
ScriptWorkerIdentity.TryResolveSid(@"nt service\otopcuascriptworker", out var lower, out _).ShouldBeTrue();
lower.ShouldBe(OtOpcUaScriptWorkerSid);
}
[Fact]
public void A_sid_string_passes_through()
{
ScriptWorkerIdentity.TryResolveSid("S-1-5-21-1-2-3-1001", out var sid, out _).ShouldBeTrue();
sid.ShouldBe("S-1-5-21-1-2-3-1001");
}
[Theory]
[InlineData("", "no identity")]
[InlineData(@"OTOPCUA\svc-worker", "expected 'NT SERVICE")]
[InlineData(@"NT SERVICE\bad name", "virtual account name")]
[InlineData("S-1-1-0", "well-known group")]
[InlineData("S-1-5-32-545", "well-known group")]
[InlineData("S-1-5-18", "well-known group")]
[InlineData("S-1-5-11", "well-known group")]
public void A_domain_account_a_well_known_group_or_garbage_is_refused(string identity, string why)
{
ScriptWorkerIdentity.TryResolveSid(identity, out _, out var error).ShouldBeFalse();
error!.ShouldContain(why);
}
[Fact]
public void The_restricting_set_is_the_identity_then_everyone_users_and_restricted_and_never_authenticated_users()
{
var set = ScriptWorkerIdentity.RestrictingSids(OtOpcUaScriptWorkerSid);
set.ShouldBe([OtOpcUaScriptWorkerSid, "S-1-1-0", "S-1-5-32-545", "S-1-5-12"]);
set.ShouldNotContain("S-1-5-11");
}
[Fact]
public void Validation_refuses_an_identity_off_windows_and_a_malformed_one_on_windows()
{
new ScriptWorkerOptions().ValidateIdentity(isWindows: false).ShouldBeNull("unset is always fine");
new ScriptWorkerOptions { Identity = ScriptWorkerIdentity.DefaultIdentity }.ValidateIdentity(isWindows: true)
.ShouldBeNull("positive control");
new ScriptWorkerOptions { Identity = ScriptWorkerIdentity.DefaultIdentity }.ValidateIdentity(isWindows: false)
.ShouldNotBeNull().ShouldContain("only supported on Windows");
new ScriptWorkerOptions { Identity = @"OTOPCUA\svc" }.ValidateIdentity(isWindows: true)
.ShouldNotBeNull().ShouldContain("Scripting:Worker:Identity");
}
[Fact]
public void Sanitized_keeps_the_identity()
=> new ScriptWorkerOptions { Identity = ScriptWorkerIdentity.DefaultIdentity }.Sanitized(out _).Identity
.ShouldBe(ScriptWorkerIdentity.DefaultIdentity);
}
@@ -45,6 +45,7 @@ public sealed class ScriptRunnerSelectionTests
["Scripting:Worker:IsolationProbationRuns"] = "9",
["Scripting:Worker:PeCacheDir"] = "/var/cache/otopcua-pe",
["Scripting:Worker:ExecutablePath"] = "/opt/w/ZB.MOM.WW.OtOpcUa.ScriptWorker.dll",
["Scripting:Worker:Identity"] = @"NT SERVICE\OtOpcUaScriptWorker",
}), out var adjustments);
adjustments.ShouldBeEmpty();
@@ -58,6 +59,7 @@ public sealed class ScriptRunnerSelectionTests
bound.Worker.IsolationProbationRuns.ShouldBe(9);
bound.Worker.PeCacheDir.ShouldBe("/var/cache/otopcua-pe");
bound.Worker.ExecutablePath.ShouldBe("/opt/w/ZB.MOM.WW.OtOpcUa.ScriptWorker.dll");
bound.Worker.Identity.ShouldBe(@"NT SERVICE\OtOpcUaScriptWorker");
bound.IsOutOfProcess(ScriptSeam.Calculation).ShouldBeTrue();
bound.IsOutOfProcess(ScriptSeam.VirtualTag).ShouldBeFalse();
}
@@ -115,6 +117,36 @@ public sealed class ScriptRunnerSelectionTests
Should.Throw<InvalidOperationException>(() => ScriptRunnerSelection.Validate(options, root)).Message.ShouldContain(why);
}
[Fact]
public void A_worker_identity_is_refused_at_startup_off_Windows_and_accepted_on_it()
{
var options = new ScriptingOptions
{
Runner = ScriptRunnerMode.OutOfProcess,
Worker = new ScriptWorkerOptions { Identity = @"NT SERVICE\OtOpcUaScriptWorker" },
};
if (OperatingSystem.IsWindows())
Should.NotThrow(() => ScriptRunnerSelection.Validate(options, "C:\\srv"));
else
Should.Throw<InvalidOperationException>(() => ScriptRunnerSelection.Validate(options, "/srv"))
.Message.ShouldContain("only supported on Windows");
}
[Theory]
[InlineData(@"OTOPCUA\svc-worker")]
[InlineData("S-1-5-32-545")]
[InlineData(@"NT SERVICE\bad name")]
public void A_malformed_or_shared_worker_identity_fails_startup(string identity)
{
var options = new ScriptingOptions
{
Runner = ScriptRunnerMode.OutOfProcess,
Worker = new ScriptWorkerOptions { Identity = identity },
};
Should.Throw<InvalidOperationException>(() => ScriptRunnerSelection.Validate(options, "/srv"))
.Message.ShouldContain("Scripting:Worker:Identity");
}
[Fact]
public void A_pe_cache_dir_outside_the_content_root_is_accepted()
{
@@ -0,0 +1,250 @@
using System.ComponentModel;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Principal;
using Microsoft.Win32.SafeHandles;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// <summary>
/// lmxopcua#780 design §6 / §14 #5 — the worker's own identity: the host-side half of the installer work. The pipe ACL
/// admits the host account and the worker identity only; the worker runs under a restricted, privilege-free token;
/// its working directory is the one host-temp place it may write. The Windows cases run on windev and change no
/// machine state (a pipe, a per-test temp directory and a child process, all gone at the end); nothing here creates
/// an account, a firewall rule or an ACL outside the test's own temp directory.
/// </summary>
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class WorkerIsolationTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static string WorkerSid => ScriptWorkerIdentity.ServiceSid(ScriptWorkerIdentity.DefaultServiceName);
private static WorkerLaunchSpec Spec(string? workerSid) => new("0", "/w/ZB.MOM.WW.OtOpcUa.ScriptWorker", 256L * 1024 * 1024, 2,
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(15), WorkerSid: workerSid);
// ----- any OS -----
[Fact]
public void A_restricted_worker_gets_its_working_directory_as_temp_and_an_unrestricted_one_keeps_the_hosts()
{
var restricted = WorkerConnection.BuildStartInfo(Spec(WorkerSid), "p", "n", "/work/dir");
restricted.Environment["TEMP"].ShouldBe("/work/dir");
restricted.Environment["TMP"].ShouldBe("/work/dir");
var plain = WorkerConnection.BuildStartInfo(Spec(null), "p", "n", "/work/dir");
plain.Environment.TryGetValue("TEMP", out var temp);
temp.ShouldNotBe("/work/dir", "positive control: only the restricted worker is redirected");
}
[Fact]
public void The_command_line_quotes_paths_with_spaces_and_escapes_quotes_and_trailing_backslashes()
{
WindowsProcessText.BuildCommandLine(@"C:\Program Files\dotnet\dotnet.exe", ["exec", @"C:\a b\w.dll"])
.ShouldBe(@"""C:\Program Files\dotnet\dotnet.exe"" exec ""C:\a b\w.dll""");
WindowsProcessText.BuildCommandLine(@"C:\w.exe", [@"say ""hi""", @"C:\dir with space\", ""])
.ShouldBe(@"C:\w.exe ""say \""hi\"""" ""C:\dir with space\\"" """"");
}
[Fact]
public void The_environment_block_is_sorted_and_double_nul_terminated()
{
var block = WindowsProcessText.BuildEnvironmentBlock(new Dictionary<string, string?> { ["b"] = "2", ["A"] = "1", ["skip"] = null });
block.ShouldBe("A=1\0b=2\0\0");
WindowsProcessText.BuildEnvironmentBlock(new Dictionary<string, string?>()).ShouldBe("\0\0");
}
[Fact]
public void A_runner_configured_with_an_identity_off_windows_refuses_to_start_rather_than_fall_back()
{
if (OperatingSystem.IsWindows()) Assert.Skip("the off-Windows refusal; Windows applies the identity (tests below)");
var options = Scripts.Options();
options.Worker.Identity = ScriptWorkerIdentity.DefaultIdentity;
Should.Throw<InvalidOperationException>(() => Scripts.Runner(options)).Message.ShouldContain("only supported on Windows");
}
// ----- Windows (windev) -----
[Fact]
public void The_pipe_acl_is_protected_and_admits_exactly_the_host_and_the_worker_identity()
{
if (!OperatingSystem.IsWindows()) Assert.Skip("Windows pipe ACL");
else AssertPipeAcl();
}
[Fact]
public async Task A_live_pipe_carries_the_acl_and_a_host_only_pipe_admits_the_host_alone()
{
if (!OperatingSystem.IsWindows()) Assert.Skip("Windows pipe ACL");
else await AssertLivePipeAcl();
}
[Fact]
public void The_worker_token_is_restricted_privilege_free_and_reaches_only_what_its_identity_may()
{
if (!OperatingSystem.IsWindows()) Assert.Skip("Windows restricted token");
else AssertTokenAccess();
}
[Fact]
public async Task A_worker_under_the_identity_runs_scripts_with_a_restricted_token_and_a_plain_one_does_not()
{
if (!OperatingSystem.IsWindows()) Assert.Skip("Windows restricted launch");
else await AssertRestrictedLaunch();
}
[SupportedOSPlatform("windows")]
private static void AssertPipeAcl()
{
using var me = WindowsIdentity.GetCurrent();
var worker = new SecurityIdentifier(WorkerSid);
var security = WorkerPipeSecurity.Create(me.Owner!, me.User!, worker);
security.AreAccessRulesProtected.ShouldBeTrue("no inherited ACEs");
security.GetOwner(typeof(SecurityIdentifier)).ShouldBe(me.Owner);
var rules = security.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast<PipeAccessRule>().ToArray();
rules.ShouldAllBe(r => r.AccessControlType == AccessControlType.Allow
&& (r.PipeAccessRights & WorkerPipeSecurity.GrantedRights) == WorkerPipeSecurity.GrantedRights
&& (r.PipeAccessRights & (PipeAccessRights.ChangePermissions | PipeAccessRights.TakeOwnership
| PipeAccessRights.CreateNewInstance)) == 0);
var expected = new[] { me.User!, worker }.Select(s => s.Value).Order().ToArray();
rules.Select(r => r.IdentityReference.Value).Order().ToArray().ShouldBe(expected);
rules.ShouldNotContain(r => r.IdentityReference.Value == "S-1-5-18" || r.IdentityReference.Value == "S-1-1-0"
|| r.IdentityReference.Value == "S-1-5-32-544", "no SYSTEM / Everyone / Administrators");
var hostOnly = WorkerPipeSecurity.Create(me.Owner!, me.User!, null);
hostOnly.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast<PipeAccessRule>()
.ShouldNotContain(r => r.IdentityReference.Value == worker.Value, "no identity configured: the host alone");
}
[SupportedOSPlatform("windows")]
private static async Task AssertLivePipeAcl()
{
var name = "otopcua-sw-acl-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(6));
await using var server = WorkerPipeSecurity.CreateServer(name, WorkerPipeSecurity.ForCurrentHost(WorkerSid));
var live = server.GetAccessControl();
live.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast<PipeAccessRule>()
.ShouldContain(r => r.IdentityReference.Value == WorkerSid);
// The worker's client still authenticates the server end (owner == its token owner), exactly as before.
await using var client = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
var accept = server.WaitForConnectionAsync(Ct);
await client.ConnectAsync(TimeSpan.FromSeconds(5), Ct);
await accept;
server.IsConnected.ShouldBeTrue();
}
[SupportedOSPlatform("windows")]
private static void AssertTokenAccess()
{
using var temp = new Scripts.TempDir();
var hostOnlyFile = Path.Combine(temp.Path, "host-only.txt");
File.WriteAllText(hostOnlyFile, "secret");
var workDir = Path.Combine(temp.Path, "work");
Directory.CreateDirectory(workDir);
RestrictedWorkerLauncher.GrantWorkDirectory(workDir, WorkerSid);
using var token = RestrictedWorkerLauncher.CreateWorkerToken(WorkerSid);
IsTokenRestricted(token).ShouldBeTrue();
PrivilegesIn(token).ShouldBeSubsetOf(["SeChangeNotifyPrivilege"]);
WindowsIdentity.RunImpersonated(token, () =>
{
// The runtime's own files: readable through BUILTIN\Users.
File.ReadAllBytes(Path.Combine(Environment.SystemDirectory, "kernel32.dll")).Length.ShouldBeGreaterThan(0);
// The work directory: writable through the identity's ACE (positive control).
File.WriteAllText(Path.Combine(workDir, "ok.txt"), "x");
// The host's temp: the host account may, the identity may not, so the worker may not.
Should.Throw<UnauthorizedAccessException>(() => File.ReadAllText(hostOnlyFile));
Should.Throw<UnauthorizedAccessException>(() => File.WriteAllText(Path.Combine(temp.Path, "nope.txt"), "x"));
});
File.Exists(hostOnlyFile).ShouldBeTrue();
// Unrestricted, the same thread reads it (positive control for the negative assertion above).
File.ReadAllText(hostOnlyFile).ShouldBe("secret");
}
[SupportedOSPlatform("windows")]
private static async Task AssertRestrictedLaunch()
{
var options = Scripts.Options();
options.Worker.Identity = ScriptWorkerIdentity.DefaultIdentity;
var sink = new CapturingSink();
await using (var runner = Scripts.Runner(options, sink.CreateLogger()))
{
runner.WorkerSid.ShouldBe(WorkerSid);
var outcome = await runner.RunAsync(Scripts.Vt("vt", "return (int)ctx.GetTag(\"x\").Value + 1;", inputs: Scripts.Input("x", 41)), Ct);
outcome.Status.ShouldBe(ScriptRunStatus.Ok, $"{outcome.Reason}; host log: {string.Join(" | ", sink.Events.Select(e => e.RenderMessage() + " " + e.Exception))}");
outcome.Value.ShouldBe(42);
var pid = runner.Shards[0].Current.ShouldNotBeNull().Pid;
WorkerTokenIsRestricted(pid).ShouldBeTrue("the worker runs under the restricted token");
}
await using (var plain = Scripts.Runner(Scripts.Options()))
{
(await plain.RunAsync(Scripts.Vt("vt", "return 1;"), Ct)).Status.ShouldBe(ScriptRunStatus.Ok);
WorkerTokenIsRestricted(plain.Shards[0].Current.ShouldNotBeNull().Pid)
.ShouldBeFalse("positive control: without an identity the worker keeps the host's token");
}
}
[SupportedOSPlatform("windows")]
private static bool WorkerTokenIsRestricted(int pid)
{
using var process = System.Diagnostics.Process.GetProcessById(pid);
if (!OpenProcessToken(process.Handle, TokenQuery, out var token)) throw new Win32Exception(Marshal.GetLastWin32Error());
using (token) return IsTokenRestricted(token);
}
[SupportedOSPlatform("windows")]
private static string[] PrivilegesIn(SafeAccessTokenHandle token)
{
GetTokenInformation(token, TokenPrivilegesClass, IntPtr.Zero, 0, out var length);
var buffer = Marshal.AllocHGlobal(length);
try
{
if (!GetTokenInformation(token, TokenPrivilegesClass, buffer, length, out _)) throw new Win32Exception(Marshal.GetLastWin32Error());
var count = Marshal.ReadInt32(buffer);
var names = new List<string>();
for (var i = 0; i < count; i++)
{
var entry = buffer + 4 + (i * 12);
var luid = Marshal.ReadInt64(entry);
var size = 256;
var name = new System.Text.StringBuilder(size);
if (LookupPrivilegeNameW(null, ref luid, name, ref size)) names.Add(name.ToString());
}
return [.. names];
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
private const uint TokenQuery = 0x0008;
private const int TokenPrivilegesClass = 3;
#pragma warning disable SYSLIB1054
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool OpenProcessToken(IntPtr process, uint access, out SafeAccessTokenHandle token);
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool IsTokenRestricted(SafeAccessTokenHandle token);
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetTokenInformation(SafeAccessTokenHandle token, int infoClass, IntPtr info, int length, out int returned);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool LookupPrivilegeNameW(string? systemName, ref long luid, System.Text.StringBuilder name, ref int size);
#pragma warning restore SYSLIB1054
}