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