2a8e2981fe
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
251 lines
12 KiB
C#
251 lines
12 KiB
C#
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
|
|
}
|