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,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
}