diff --git a/CLAUDE.md b/CLAUDE.md index a9b9c1ee..9f1aaa39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/docs/ServiceHosting.md b/docs/ServiceHosting.md index 06263ec5..4ba5f987 100644 --- a/docs/ServiceHosting.md +++ b/docs/ServiceHosting.md @@ -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 -HostServiceAccount [-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 `` (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 diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md index be47d254..5c66d01a 100644 --- a/docs/VirtualTags.md +++ b/docs/VirtualTags.md @@ -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`, 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. diff --git a/docs/plans/2026-11-09-out-of-process-script-runner-design.md b/docs/plans/2026-11-09-out-of-process-script-runner-design.md index 571a75c3..234aefca 100644 --- a/docs/plans/2026-11-09-out-of-process-script-runner-design.md +++ b/docs/plans/2026-11-09-out-of-process-script-runner-design.md @@ -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\` 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 diff --git a/scripts/install/Install-ScriptWorkerIsolation.ps1 b/scripts/install/Install-ScriptWorkerIsolation.ps1 new file mode 100644 index 00000000..6f46f311 --- /dev/null +++ b/scripts/install/Install-ScriptWorkerIsolation.ps1 @@ -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 '' whose account is NT SERVICE\, + 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 \pe, the worker identity gets Modify (workers compile and write HMAC'd entries). + 4. WorkerFolderAcl \script-worker, the worker identity gets read + execute. + 5. InstallRootDeny , 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 + \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 diff --git a/scripts/install/ScriptWorkerIsolation.psm1 b/scripts/install/ScriptWorkerIsolation.psm1 new file mode 100644 index 00000000..2ff6622d --- /dev/null +++ b/scripts/install/ScriptWorkerIsolation.psm1 @@ -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\. 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\: 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 ` 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 diff --git a/scripts/install/tests/Test-ScriptWorkerIsolation.ps1 b/scripts/install/tests/Test-ScriptWorkerIsolation.ps1 new file mode 100644 index 00000000..0b2e655a --- /dev/null +++ b/scripts/install/tests/Test-ScriptWorkerIsolation.ps1 @@ -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 \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 } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/OutOfProcessScriptRunner.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/OutOfProcessScriptRunner.cs index 1892febc..4497440c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/OutOfProcessScriptRunner.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/OutOfProcessScriptRunner.cs @@ -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, /// The worker entry assembly this runner launches. public string WorkerPath { get; } + /// The worker identity's SID (Scripting:Worker:Identity), or null when workers keep the host's token. + public string? WorkerSid { get; } + /// The overall state (the readiness check, the state gauge). 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); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/RestrictedWorkerLauncher.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/RestrictedWorkerLauncher.cs new file mode 100644 index 00000000..965b009f --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/RestrictedWorkerLauncher.cs @@ -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; + +/// +/// Starts a ScriptWorker under a restricted token for Scripting:Worker:Identity (lmxopcua#780 design §6, owner +/// decision §14 #5; see for why a restricted token and not a logon). The launch: +/// +/// derives a primary token from the host's with DISABLE_MAX_PRIVILEGE and the restricting SIDs +/// () — no password, no SeAssignPrimaryTokenPrivilege +/// (a restricted copy of the caller's own token does not need it); +/// sets the token's default DACL, and the token object's own DACL, to the host account, SYSTEM, the worker +/// identity and RESTRICTED, 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; +/// creates the process suspended with CreateProcessAsUserW, inheriting exactly its stdout and +/// stderr pipe ends (PROC_THREAD_ATTRIBUTE_HANDLE_LIST), with the allow-listed environment block; +/// assigns it to the worker's Job Object before its first instruction runs, then resumes it. +/// +/// +[SupportedOSPlatform("windows")] +internal static class RestrictedWorkerLauncher +{ + /// A started worker: its process, its job and the two stdio readers. + 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; + } + + /// The worker process (opened by pid while the creation handle was still held, so it cannot be a reused pid). + public Process Process { get; } + + /// The worker's Job Object (kill on close). + public WindowsJobObject Job { get; } + + /// Closes the stdio readers (the process and job are owned by the caller). + public void Dispose() + { + _stdout.Dispose(); + _stderr.Dispose(); + } + } + + /// Starts under a restricted token for . + /// What to run: file name, argument list, working directory and the final environment. + /// The worker identity's SID string. + /// The Job Object's per-process commit limit. + /// Receives each stdout line. + /// Receives each stderr line. + /// The started worker. + public static Launched Start(ProcessStartInfo psi, string workerSid, long processMemoryLimitBytes, Action onStdout, + Action 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(), + 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); + } + } + + /// + /// The worker's primary token: the host's, with every privilege but SeChangeNotifyPrivilege removed, the + /// restricting SIDs of , and a default DACL of host account + SYSTEM + worker identity. + /// + /// The worker identity's SID string. + /// The token (owned by the caller). + 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(); + var entries = IntPtr.Zero; + try + { + var entrySize = Marshal.SizeOf(); + 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 SessionGranted = new(StringComparer.Ordinal); + + /// + /// 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 coreclr.dll (ERROR_DLL_INIT_FAILED, measured on windev). The + /// logon SID, which those objects normally grant, is deliberately NOT a restricting SID (the host's process DACL + /// gives it PROCESS_VM_READ). 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. + /// + /// The worker identity's SID string. + 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; + } + } + } + + /// The rights the worker identity gets on the window station: enumerate, read attributes, global atoms. + internal const int WindowStationRights = 0x0001 | 0x0002 | 0x0020 | 0x0100 | 0x00020000; + + /// The rights the worker identity gets on the desktop: read objects, create windows (USER32 init), enumerate. + 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()) + 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); + } + } + + /// The SDDL of the default DACL a worker token carries (tests). + /// The host account. + /// The worker identity. + /// The raw ACL. + 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())) + 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(); + } + } + + /// + /// 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 (TEMP/TMP point at it). + /// + /// The working directory the host created. + /// The worker identity's SID string. + 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 { 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 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 +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsJobObject.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsJobObject.cs index 449deade..9a16fc8f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsJobObject.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsJobObject.cs @@ -29,6 +29,16 @@ internal sealed class WindowsJobObject : IDisposable /// The per-process commit limit. /// The job; dispose it to kill the worker. public static WindowsJobObject Assign(Process process, long processMemoryLimitBytes) + => Assign(process.Handle, processMemoryLimitBytes); + + /// + /// Creates a job with the worker limits and assigns the process behind to it — the + /// restricted launch assigns its worker while it is still suspended (). + /// + /// A handle with PROCESS_SET_QUOTA and PROCESS_TERMINATE. + /// The per-process commit limit. + /// The job; dispose it to kill the worker. + 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())) 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; } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsProcessText.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsProcessText.cs new file mode 100644 index 00000000..d58b47cb --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsProcessText.cs @@ -0,0 +1,64 @@ +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess; + +/// +/// The text a native Windows process launch takes (): the command line and the +/// environment block. Pure string work, so it is testable on any OS. +/// +internal static class WindowsProcessText +{ + /// A Windows command line for and (MSVC quoting rules). + /// The executable. + /// The arguments. + /// The command line. + internal static string BuildCommandLine(string fileName, IEnumerable 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('"'); + } + + /// A sorted, double-NUL-terminated UTF-16 environment block. + /// The variables. + /// The block. + internal static string BuildEnvironmentBlock(IDictionary 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(); + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerConnection.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerConnection.cs index 9b0d91d0..85b6f6d9 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerConnection.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerConnection.cs @@ -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. /// /// The worker's dedicated compile threads; 0 = its default. +/// +/// Windows only: the worker identity's SID (Scripting:Worker:Identity). Set, the worker starts under a restricted +/// token () and the pipe ACL admits it beside the host; null keeps the host's token. +/// 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) { /// The default compile timeout. 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 _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 onLogs, TimeSpan heartbeatInterval, TimeSpan loadTimeout, Action onExiting, - string? markerPath) + private WorkerConnection(string shard, Process process, NamedPipeServerStream pipe, IDisposable? job, IDisposable? stdio, + string workDir, ILogger log, Action onLogs, TimeSpan heartbeatInterval, TimeSpan loadTimeout, + Action 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); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerPipeSecurity.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerPipeSecurity.cs new file mode 100644 index 00000000..5c491e7a --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerPipeSecurity.cs @@ -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; + +/// +/// 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 Scripting:Worker:Identity 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 PipeOptions.CurrentUserOnly sets it, so the worker's +/// CurrentUserOnly client check (pipe owner == its token owner, which a restricted token keeps) still +/// authenticates the server end. +/// +[SupportedOSPlatform("windows")] +internal static class WorkerPipeSecurity +{ + /// The rights each admitted principal gets: read/write data, attributes and the security descriptor (read). + internal const PipeAccessRights GrantedRights = PipeAccessRights.ReadWrite; + + /// Builds the pipe ACL for this host process. + /// The worker identity's SID string, or null when the worker keeps the host's token. + /// The pipe security. + 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)); + } + + /// Builds the pipe ACL. + /// The pipe owner (the host token's owner). + /// The host account (its token's user SID). + /// The worker identity, or null. + /// The pipe security: protected, allow-only, one ACE for the host account and one for the worker identity. + 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 { 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; + } + + /// Creates the single-instance server end of a worker pipe with . + /// The random pipe name. + /// The ACL (). + /// The server stream. + public static NamedPipeServerStream CreateServer(string pipeName, PipeSecurity security) + => NamedPipeServerStreamAcl.Create(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous, inBufferSize: 0, outBufferSize: 0, security); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunnerOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunnerOptions.cs index 4d1c1539..78704986 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunnerOptions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunnerOptions.cs @@ -138,6 +138,17 @@ public sealed class ScriptWorkerOptions /// public string? ExecutablePath { get; set; } + /// + /// The worker's identity on Windows (design §6, owner decision §14 #5): NT SERVICE\<name> — the virtual + /// account scripts/install/Install-ScriptWorkerIsolation.ps1 registers, by default + /// NT SERVICE\OtOpcUaScriptWorker — or a SID string. When set, each worker starts under a restricted token + /// derived from the host's (see ): every privilege removed, and every access check + /// must ALSO pass for this identity, Everyone, BUILTIN\Users or RESTRICTED. 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. + /// + public string? Identity { get; set; } + /// The hard grace that applies to a run with soft budget . /// The run's soft deadline T. /// when set, otherwise max(1 s, T). @@ -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; } + + /// + /// Fail-closed identity validation: , when set, must be NT SERVICE\<name> or a SID + /// string, and the host must run on Windows. + /// + /// Whether the host runs on Windows (callers pass ). + /// The violation, or null when the identity is unset or acceptable. + 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}"; + } } /// diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptWorkerIdentity.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptWorkerIdentity.cs new file mode 100644 index 00000000..669f1844 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptWorkerIdentity.cs @@ -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; + +/// +/// The ScriptWorker's Windows identity (Scripting:Worker:Identity, lmxopcua#780 design §6, owner decision §14 #5). +/// +/// +/// +/// Why a restricted token and not a logon. Windows starts a process under a virtual account +/// (NT SERVICE\name) 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 SeAssignPrimaryTokenPrivilege on the host account. Instead the host derives a +/// restricted token 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 restricting SIDs, so every +/// access check must pass twice — once for the host account and once for the restricting set +/// (). The worker can therefore reach only what BOTH the host account AND the worker +/// identity (or Everyone / BUILTIN\Users / RESTRICTED, which the runtime needs to load from +/// System32) may reach: in effect a standard user bounded by the host account, with no privileges. +/// +/// +/// The virtual account's SID is computable from its name (), 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. +/// +/// +public static partial class ScriptWorkerIdentity +{ + /// The installer's default virtual account name (the anchor service's name). + public const string DefaultServiceName = "OtOpcUaScriptWorker"; + + /// The installer's default identity, as configured. + public const string DefaultIdentity = @"NT SERVICE\" + DefaultServiceName; + + /// Everyone. + public const string EveryoneSid = "S-1-1-0"; + + /// BUILTIN\Users. + public const string BuiltinUsersSid = "S-1-5-32-545"; + + /// NT AUTHORITY\RESTRICTED (the SID many system objects grant for restricted tokens). + public const string RestrictedSid = "S-1-5-12"; + + private const string ServicePrefix = @"NT SERVICE\"; + + /// + /// The restricting SIDs of a worker token for : the identity itself, then + /// Everyone, BUILTIN\Users and RESTRICTED. Authenticated Users 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). + /// + /// The worker identity's SID string. + /// The restricting SID strings, identity first. + public static IReadOnlyList RestrictingSids(string workerSid) => [workerSid, EveryoneSid, BuiltinUsersSid, RestrictedSid]; + + /// + /// The SID Windows assigns the virtual account NT SERVICE\: + /// S-1-5-80- followed by the SHA-1 of the upper-cased name in UTF-16LE, read as five little-endian + /// 32-bit values (the algorithm behind sc.exe showsid). + /// + /// The service name (no NT SERVICE\ prefix). + /// The SID string. + 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(); + } + + /// Resolves a configured identity to a SID string without touching the OS. + /// NT SERVICE\name or a SID string (S-1-…). + /// The SID string. + /// Why it was refused. + /// True when resolved. + 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\' (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(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptRunnerSelection.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptRunnerSelection.cs index ca50071f..2fc22967 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptRunnerSelection.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptRunnerSelection.cs @@ -68,7 +68,8 @@ public sealed class ScriptRunnerSelection : IScriptRunnerProvider, IAsyncDisposa /// /// Startup checks for the runner options: returns the Warnings to log, and throws when the configuration must - /// not start (a PeCacheDir that is relative or inside the content root — fail closed). + /// not start (a PeCacheDir that is relative or inside the content root, or a Worker:Identity that + /// is malformed or set off Windows — fail closed). /// /// The bound, sanitized Scripting options. /// The host's content root. @@ -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; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker/WorkerHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker/WorkerHost.cs index b7e0751d..5b0d9985 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker/WorkerHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker/WorkerHost.cs @@ -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) diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptWorkerIdentityTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptWorkerIdentityTests.cs new file mode 100644 index 00000000..e2c770ba --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptWorkerIdentityTests.cs @@ -0,0 +1,76 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// Scripting:Worker:Identity (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, scripts/install/tests/Test-ScriptWorkerIsolation.ps1 pins the same value) and the pipe ACL agree. +/// +[Trait("Category", "Unit")] +public sealed class ScriptWorkerIdentityTests +{ + /// The installer's default identity; the PowerShell harness pins the same string. + 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); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptRunnerSelectionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptRunnerSelectionTests.cs index 02cd7753..db1046ed 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptRunnerSelectionTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptRunnerSelectionTests.cs @@ -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(() => 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(() => 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(() => ScriptRunnerSelection.Validate(options, "/srv")) + .Message.ShouldContain("Scripting:Worker:Identity"); + } + [Fact] public void A_pe_cache_dir_outside_the_content_root_is_accepted() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests/WorkerIsolationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests/WorkerIsolationTests.cs new file mode 100644 index 00000000..a88bdd18 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests/WorkerIsolationTests.cs @@ -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; + +/// +/// 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. +/// +[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 { ["b"] = "2", ["A"] = "1", ["skip"] = null }); + block.ShouldBe("A=1\0b=2\0\0"); + WindowsProcessText.BuildEnvironmentBlock(new Dictionary()).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(() => 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().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() + .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() + .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(() => File.ReadAllText(hostOnlyFile)); + Should.Throw(() => 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(); + 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 +}