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:
@@ -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