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 }