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

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

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

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 08:46:23 -05:00
parent 0e96dd08a2
commit 2a8e2981fe
20 changed files with 1898 additions and 37 deletions
@@ -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
}