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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user