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;
///
/// 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.
///
[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 { ["b"] = "2", ["A"] = "1", ["skip"] = null });
block.ShouldBe("A=1\0b=2\0\0");
WindowsProcessText.BuildEnvironmentBlock(new Dictionary()).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(() => 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().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()
.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()
.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(() => File.ReadAllText(hostOnlyFile));
Should.Throw(() => 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();
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
}