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:
@@ -165,6 +165,13 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
|
||||
_scriptRoot = scriptRoot ?? _log;
|
||||
LoadTimeout = loadTimeout;
|
||||
WorkerPath = ResolveWorkerPath(_worker);
|
||||
// Fail closed (startup also refuses it, ScriptRunnerSelection.Validate): a configured identity that cannot be
|
||||
// applied must never silently fall back to the host's own token.
|
||||
if (_worker.ValidateIdentity(OperatingSystem.IsWindows()) is { } identityViolation)
|
||||
throw new InvalidOperationException($"Scripting misconfiguration: {identityViolation}");
|
||||
WorkerSid = string.IsNullOrWhiteSpace(_worker.Identity)
|
||||
? null
|
||||
: ScriptWorkerIdentity.TryResolveSid(_worker.Identity, out var sid, out _) ? sid : null;
|
||||
|
||||
// The PE cache is best effort: a directory or key that cannot be used is a Warning and a cache-less runner,
|
||||
// never a startup failure (the path RULE is enforced at startup by ScriptRunnerSelection.Validate).
|
||||
@@ -211,6 +218,9 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
|
||||
/// <summary>The worker entry assembly this runner launches.</summary>
|
||||
public string WorkerPath { get; }
|
||||
|
||||
/// <summary>The worker identity's SID (<c>Scripting:Worker:Identity</c>), or null when workers keep the host's token.</summary>
|
||||
public string? WorkerSid { get; }
|
||||
|
||||
/// <summary>The overall state (the readiness check, the state gauge).</summary>
|
||||
public ScriptRunnerState State => ComputeState(_shards.Select(s => s.DownFor).ToArray(), _isolation.DownFor, _worker.CircuitOpenAfter);
|
||||
|
||||
@@ -693,7 +703,8 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
|
||||
PeCacheKey: _peCacheKey,
|
||||
MarkerDirectory: _markerDirectory,
|
||||
LoadTimeout: LoadTimeout,
|
||||
CompileThreads: _compileThreads);
|
||||
CompileThreads: _compileThreads,
|
||||
WorkerSid: WorkerSid);
|
||||
return new WorkerShard(label, isolation,
|
||||
(onExiting, ct) => WorkerConnection.StartAsync(spec, _log, OnLogs, onExiting, ct),
|
||||
OnWorkerExit, _log);
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a ScriptWorker under a restricted token for <c>Scripting:Worker:Identity</c> (lmxopcua#780 design §6, owner
|
||||
/// decision §14 #5; see <see cref="ScriptWorkerIdentity"/> for why a restricted token and not a logon). The launch:
|
||||
/// <list type="number">
|
||||
/// <item>derives a primary token from the host's with <c>DISABLE_MAX_PRIVILEGE</c> and the restricting SIDs
|
||||
/// (<see cref="ScriptWorkerIdentity.RestrictingSids"/>) — no password, no <c>SeAssignPrimaryTokenPrivilege</c>
|
||||
/// (a restricted copy of the caller's own token does not need it);</item>
|
||||
/// <item>sets the token's default DACL, and the token object's own DACL, to the host account, SYSTEM, the worker
|
||||
/// identity and <c>RESTRICTED</c>, so the worker can open its own token and process, the worker's
|
||||
/// process object stays openable by the host (kill, exit code, the Job Object) and objects the worker creates are
|
||||
/// reachable by both;</item>
|
||||
/// <item>creates the process <b>suspended</b> with <c>CreateProcessAsUserW</c>, inheriting exactly its stdout and
|
||||
/// stderr pipe ends (<c>PROC_THREAD_ATTRIBUTE_HANDLE_LIST</c>), with the allow-listed environment block;</item>
|
||||
/// <item>assigns it to the worker's Job Object <b>before</b> its first instruction runs, then resumes it.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class RestrictedWorkerLauncher
|
||||
{
|
||||
/// <summary>A started worker: its process, its job and the two stdio readers.</summary>
|
||||
internal sealed class Launched : IDisposable
|
||||
{
|
||||
private readonly AnonymousPipeServerStream _stdout;
|
||||
private readonly AnonymousPipeServerStream _stderr;
|
||||
|
||||
internal Launched(Process process, WindowsJobObject job, AnonymousPipeServerStream stdout, AnonymousPipeServerStream stderr)
|
||||
{
|
||||
Process = process;
|
||||
Job = job;
|
||||
_stdout = stdout;
|
||||
_stderr = stderr;
|
||||
}
|
||||
|
||||
/// <summary>The worker process (opened by pid while the creation handle was still held, so it cannot be a reused pid).</summary>
|
||||
public Process Process { get; }
|
||||
|
||||
/// <summary>The worker's Job Object (kill on close).</summary>
|
||||
public WindowsJobObject Job { get; }
|
||||
|
||||
/// <summary>Closes the stdio readers (the process and job are owned by the caller).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_stdout.Dispose();
|
||||
_stderr.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts <paramref name="psi"/> under a restricted token for <paramref name="workerSid"/>.</summary>
|
||||
/// <param name="psi">What to run: file name, argument list, working directory and the final environment.</param>
|
||||
/// <param name="workerSid">The worker identity's SID string.</param>
|
||||
/// <param name="processMemoryLimitBytes">The Job Object's per-process commit limit.</param>
|
||||
/// <param name="onStdout">Receives each stdout line.</param>
|
||||
/// <param name="onStderr">Receives each stderr line.</param>
|
||||
/// <returns>The started worker.</returns>
|
||||
public static Launched Start(ProcessStartInfo psi, string workerSid, long processMemoryLimitBytes, Action<string> onStdout,
|
||||
Action<string> onStderr)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(psi);
|
||||
GrantSessionObjects(workerSid);
|
||||
using var token = CreateWorkerToken(workerSid);
|
||||
var stdout = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
|
||||
var stderr = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
|
||||
WindowsJobObject? job = null;
|
||||
PROCESS_INFORMATION pi = default;
|
||||
var attributeList = IntPtr.Zero;
|
||||
var handleList = IntPtr.Zero;
|
||||
var environment = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
var inherit = new[] { stdout.ClientSafePipeHandle.DangerousGetHandle(), stderr.ClientSafePipeHandle.DangerousGetHandle() };
|
||||
handleList = Marshal.AllocHGlobal(IntPtr.Size * inherit.Length);
|
||||
Marshal.Copy(inherit, 0, handleList, inherit.Length);
|
||||
|
||||
var size = IntPtr.Zero;
|
||||
InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size);
|
||||
attributeList = Marshal.AllocHGlobal(size);
|
||||
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref size))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "InitializeProcThreadAttributeList failed");
|
||||
if (!UpdateProcThreadAttribute(attributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handleList,
|
||||
(IntPtr)(IntPtr.Size * inherit.Length), IntPtr.Zero, IntPtr.Zero))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "UpdateProcThreadAttribute(HANDLE_LIST) failed");
|
||||
|
||||
var si = new STARTUPINFOEX
|
||||
{
|
||||
StartupInfo = new STARTUPINFO
|
||||
{
|
||||
cb = Marshal.SizeOf<STARTUPINFOEX>(),
|
||||
dwFlags = STARTF_USESTDHANDLES,
|
||||
hStdInput = IntPtr.Zero,
|
||||
hStdOutput = inherit[0],
|
||||
hStdError = inherit[1],
|
||||
},
|
||||
lpAttributeList = attributeList,
|
||||
};
|
||||
var commandLine = new StringBuilder(WindowsProcessText.BuildCommandLine(psi.FileName, psi.ArgumentList));
|
||||
environment = Marshal.StringToHGlobalUni(WindowsProcessText.BuildEnvironmentBlock(psi.Environment));
|
||||
const uint flags = CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT;
|
||||
if (!CreateProcessAsUserW(token, psi.FileName, commandLine, IntPtr.Zero, IntPtr.Zero, bInheritHandles: true, flags,
|
||||
environment, string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory, ref si, out pi))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), $"CreateProcessAsUser for '{psi.FileName}' failed");
|
||||
|
||||
// The child owns its ends now.
|
||||
stdout.DisposeLocalCopyOfClientHandle();
|
||||
stderr.DisposeLocalCopyOfClientHandle();
|
||||
|
||||
// Into the job before it runs a single instruction: the kill-on-close and process-count limits hold from birth.
|
||||
job = WindowsJobObject.Assign(pi.hProcess, processMemoryLimitBytes);
|
||||
// Opened while the creation handle is still held, so the pid cannot have been reused; .Handle caches a handle
|
||||
// the Process object uses from here on (the token's default DACL grants the host account).
|
||||
var process = Process.GetProcessById(pi.dwProcessId);
|
||||
_ = process.Handle;
|
||||
process.EnableRaisingEvents = true;
|
||||
if (ResumeThread(pi.hThread) == uint.MaxValue)
|
||||
{
|
||||
var error = Marshal.GetLastWin32Error();
|
||||
process.Dispose();
|
||||
throw new Win32Exception(error, "ResumeThread failed");
|
||||
}
|
||||
|
||||
Pump(stdout, onStdout);
|
||||
Pump(stderr, onStderr);
|
||||
return new Launched(process, job, stdout, stderr);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (pi.hProcess != IntPtr.Zero) TerminateProcess(pi.hProcess, 1);
|
||||
job?.Dispose();
|
||||
stdout.Dispose();
|
||||
stderr.Dispose();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
|
||||
if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
|
||||
if (attributeList != IntPtr.Zero)
|
||||
{
|
||||
DeleteProcThreadAttributeList(attributeList);
|
||||
Marshal.FreeHGlobal(attributeList);
|
||||
}
|
||||
if (handleList != IntPtr.Zero) Marshal.FreeHGlobal(handleList);
|
||||
if (environment != IntPtr.Zero) Marshal.FreeHGlobal(environment);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The worker's primary token: the host's, with every privilege but <c>SeChangeNotifyPrivilege</c> removed, the
|
||||
/// restricting SIDs of <paramref name="workerSid"/>, and a default DACL of host account + SYSTEM + worker identity.
|
||||
/// </summary>
|
||||
/// <param name="workerSid">The worker identity's SID string.</param>
|
||||
/// <returns>The token (owned by the caller).</returns>
|
||||
internal static SafeAccessTokenHandle CreateWorkerToken(string workerSid)
|
||||
{
|
||||
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT | READ_CONTROL | WRITE_DAC,
|
||||
out var processToken))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "OpenProcessToken failed");
|
||||
using (processToken)
|
||||
{
|
||||
// Not the logon SID: the host's own process DACL grants it PROCESS_VM_READ (measured on windev), so a worker
|
||||
// holding it could read the host's memory.
|
||||
var restricting = ScriptWorkerIdentity.RestrictingSids(workerSid).Select(s => new SecurityIdentifier(s)).ToArray();
|
||||
var pinned = new List<GCHandle>();
|
||||
var entries = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
var entrySize = Marshal.SizeOf<SID_AND_ATTRIBUTES>();
|
||||
entries = Marshal.AllocHGlobal(entrySize * restricting.Length);
|
||||
for (var i = 0; i < restricting.Length; i++)
|
||||
{
|
||||
var bytes = new byte[restricting[i].BinaryLength];
|
||||
restricting[i].GetBinaryForm(bytes, 0);
|
||||
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
|
||||
pinned.Add(handle);
|
||||
Marshal.StructureToPtr(new SID_AND_ATTRIBUTES { Sid = handle.AddrOfPinnedObject(), Attributes = 0 },
|
||||
entries + (i * entrySize), fDeleteOld: false);
|
||||
}
|
||||
if (!CreateRestrictedToken(processToken, DISABLE_MAX_PRIVILEGE, 0, IntPtr.Zero, 0, IntPtr.Zero,
|
||||
(uint)restricting.Length, entries, out var restricted))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateRestrictedToken failed");
|
||||
try
|
||||
{
|
||||
SetDefaultDacl(restricted, new SecurityIdentifier(workerSid));
|
||||
return restricted;
|
||||
}
|
||||
catch
|
||||
{
|
||||
restricted.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var handle in pinned) handle.Free();
|
||||
if (entries != IntPtr.Zero) Marshal.FreeHGlobal(entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly object SessionGrantLock = new();
|
||||
private static readonly HashSet<string> SessionGranted = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Adds the worker identity to the DACL of the host's window station and desktop (once per process and identity).
|
||||
/// USER32's DllMain connects the process to them, and the runtime imports USER32, so a worker whose restricting SIDs
|
||||
/// have no access there fails to load <c>coreclr.dll</c> (<c>ERROR_DLL_INIT_FAILED</c>, measured on windev). The
|
||||
/// logon SID, which those objects normally grant, is deliberately NOT a restricting SID (the host's process DACL
|
||||
/// gives it <c>PROCESS_VM_READ</c>). The grant is to the identity only and lives on the session's kernel objects:
|
||||
/// it ends with the host's session and changes nothing persistent.
|
||||
/// </summary>
|
||||
/// <param name="workerSid">The worker identity's SID string.</param>
|
||||
internal static void GrantSessionObjects(string workerSid)
|
||||
{
|
||||
lock (SessionGrantLock)
|
||||
{
|
||||
if (!SessionGranted.Add(workerSid)) return;
|
||||
try
|
||||
{
|
||||
var sid = new SecurityIdentifier(workerSid);
|
||||
AddAllowAce(GetProcessWindowStation(), sid, WindowStationRights);
|
||||
AddAllowAce(GetThreadDesktop(GetCurrentThreadId()), sid, DesktopRights);
|
||||
}
|
||||
catch
|
||||
{
|
||||
SessionGranted.Remove(workerSid);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The rights the worker identity gets on the window station: enumerate, read attributes, global atoms.</summary>
|
||||
internal const int WindowStationRights = 0x0001 | 0x0002 | 0x0020 | 0x0100 | 0x00020000;
|
||||
|
||||
/// <summary>The rights the worker identity gets on the desktop: read objects, create windows (USER32 init), enumerate.</summary>
|
||||
internal const int DesktopRights = 0x0001 | 0x0002 | 0x0040 | 0x00020000;
|
||||
|
||||
private static void AddAllowAce(IntPtr handle, SecurityIdentifier sid, int mask)
|
||||
{
|
||||
if (handle == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "no window station / desktop handle");
|
||||
var error = GetSecurityInfoRaw(handle, SE_WINDOW_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero, out var daclPtr,
|
||||
IntPtr.Zero, out var sd);
|
||||
if (error != 0) throw new Win32Exception((int)error, "GetSecurityInfo(window object) failed");
|
||||
try
|
||||
{
|
||||
RawAcl acl;
|
||||
if (daclPtr == IntPtr.Zero) return; // a NULL DACL already admits everyone
|
||||
var size = Marshal.ReadInt16(daclPtr + 2);
|
||||
var bytes = new byte[size];
|
||||
Marshal.Copy(daclPtr, bytes, 0, size);
|
||||
acl = new RawAcl(bytes, 0);
|
||||
foreach (var ace in acl.OfType<CommonAce>())
|
||||
if (ace.SecurityIdentifier == sid && ace.AceQualifier == AceQualifier.AccessAllowed && (ace.AccessMask & mask) == mask) return;
|
||||
acl.InsertAce(acl.Count, new CommonAce(AceFlags.None, AceQualifier.AccessAllowed, mask, sid, false, null));
|
||||
var updated = new byte[acl.BinaryLength];
|
||||
acl.GetBinaryForm(updated, 0);
|
||||
var pinned = GCHandle.Alloc(updated, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
error = SetSecurityInfoRaw(handle, SE_WINDOW_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero,
|
||||
pinned.AddrOfPinnedObject(), IntPtr.Zero);
|
||||
if (error != 0) throw new Win32Exception((int)error, "SetSecurityInfo(window object) failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
pinned.Free();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
LocalFree(sd);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The SDDL of the default DACL a worker token carries (tests).</summary>
|
||||
/// <param name="hostUser">The host account.</param>
|
||||
/// <param name="worker">The worker identity.</param>
|
||||
/// <returns>The raw ACL.</returns>
|
||||
internal static RawAcl DefaultDacl(SecurityIdentifier hostUser, SecurityIdentifier worker)
|
||||
{
|
||||
var acl = new RawAcl(GenericAcl.AclRevision, 3);
|
||||
var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||
var restricted = new SecurityIdentifier(ScriptWorkerIdentity.RestrictedSid);
|
||||
foreach (var sid in new[] { hostUser, system, worker, restricted }.Distinct())
|
||||
acl.InsertAce(acl.Count, new CommonAce(AceFlags.None, AceQualifier.AccessAllowed, GENERIC_ALL, sid, false, null));
|
||||
return acl;
|
||||
}
|
||||
|
||||
private static void SetDefaultDacl(SafeAccessTokenHandle token, SecurityIdentifier worker)
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var acl = DefaultDacl(identity.User ?? throw new InvalidOperationException("the host token has no user"), worker);
|
||||
var bytes = new byte[acl.BinaryLength];
|
||||
acl.GetBinaryForm(bytes, 0);
|
||||
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
var info = new TOKEN_DEFAULT_DACL { DefaultDacl = handle.AddrOfPinnedObject() };
|
||||
if (!SetTokenInformation(token, TokenDefaultDacl, ref info, (uint)Marshal.SizeOf<TOKEN_DEFAULT_DACL>()))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetTokenInformation(TokenDefaultDacl) failed");
|
||||
// The token object itself: its DACL came from the host's default DACL, which no restricting SID passes, so the
|
||||
// worker could not open its own token and the loader fails (coreclr.dll, ERROR_DLL_INIT_FAILED, on windev).
|
||||
var error = SetSecurityInfo(token, SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero,
|
||||
handle.AddrOfPinnedObject(), IntPtr.Zero);
|
||||
if (error != 0) throw new Win32Exception((int)error, "SetSecurityInfo(token DACL) failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives the worker identity Modify on its per-launch working directory: protected, host account + SYSTEM full
|
||||
/// control, worker identity modify. Its temp files go there (<c>TEMP</c>/<c>TMP</c> point at it).
|
||||
/// </summary>
|
||||
/// <param name="directory">The working directory the host created.</param>
|
||||
/// <param name="workerSid">The worker identity's SID string.</param>
|
||||
public static void GrantWorkDirectory(string directory, string workerSid)
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var security = new DirectorySecurity();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
const InheritanceFlags inherit = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
|
||||
var principals = new List<SecurityIdentifier> { new(WellKnownSidType.LocalSystemSid, null) };
|
||||
if (identity.User is { } user) principals.Add(user);
|
||||
if (identity.Owner is { } owner && !principals.Contains(owner)) principals.Add(owner);
|
||||
foreach (var principal in principals)
|
||||
security.AddAccessRule(new FileSystemAccessRule(principal, FileSystemRights.FullControl, inherit, PropagationFlags.None,
|
||||
AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(workerSid), FileSystemRights.Modify | FileSystemRights.Synchronize,
|
||||
inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
new DirectoryInfo(directory).SetAccessControl(security);
|
||||
}
|
||||
|
||||
private static void Pump(AnonymousPipeServerStream stream, Action<string> onLine)
|
||||
{
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
while (reader.ReadLine() is { } line) onLine(line);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
|
||||
{
|
||||
// The worker is gone or the host is stopping.
|
||||
}
|
||||
})
|
||||
{ IsBackground = true, Name = "ScriptWorker.Stdio" };
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
private const uint TOKEN_DUPLICATE = 0x0002;
|
||||
private const uint TOKEN_ASSIGN_PRIMARY = 0x0001;
|
||||
private const uint TOKEN_QUERY = 0x0008;
|
||||
private const uint TOKEN_ADJUST_DEFAULT = 0x0080;
|
||||
private const uint READ_CONTROL = 0x00020000;
|
||||
private const uint WRITE_DAC = 0x00040000; // the restricted token inherits these rights: its own DACL is rewritten
|
||||
private const uint DISABLE_MAX_PRIVILEGE = 0x1;
|
||||
private const int TokenDefaultDacl = 6;
|
||||
private const int SE_KERNEL_OBJECT = 6;
|
||||
private const int SE_WINDOW_OBJECT = 7;
|
||||
private const uint DACL_SECURITY_INFORMATION = 0x4;
|
||||
private const int GENERIC_ALL = 0x10000000;
|
||||
private const int STARTF_USESTDHANDLES = 0x00000100;
|
||||
private const uint CREATE_SUSPENDED = 0x00000004;
|
||||
private const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
|
||||
private const uint CREATE_NO_WINDOW = 0x08000000;
|
||||
private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
|
||||
private static readonly IntPtr PROC_THREAD_ATTRIBUTE_HANDLE_LIST = (IntPtr)0x00020002;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SID_AND_ATTRIBUTES
|
||||
{
|
||||
public IntPtr Sid;
|
||||
public uint Attributes;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct TOKEN_DEFAULT_DACL
|
||||
{
|
||||
public IntPtr DefaultDacl;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct STARTUPINFO
|
||||
{
|
||||
public int cb;
|
||||
public string? lpReserved;
|
||||
public string? lpDesktop;
|
||||
public string? lpTitle;
|
||||
public int dwX;
|
||||
public int dwY;
|
||||
public int dwXSize;
|
||||
public int dwYSize;
|
||||
public int dwXCountChars;
|
||||
public int dwYCountChars;
|
||||
public int dwFillAttribute;
|
||||
public int dwFlags;
|
||||
public short wShowWindow;
|
||||
public short cbReserved2;
|
||||
public IntPtr lpReserved2;
|
||||
public IntPtr hStdInput;
|
||||
public IntPtr hStdOutput;
|
||||
public IntPtr hStdError;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct STARTUPINFOEX
|
||||
{
|
||||
public STARTUPINFO StartupInfo;
|
||||
public IntPtr lpAttributeList;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct PROCESS_INFORMATION
|
||||
{
|
||||
public IntPtr hProcess;
|
||||
public IntPtr hThread;
|
||||
public int dwProcessId;
|
||||
public int dwThreadId;
|
||||
}
|
||||
|
||||
#pragma warning disable SYSLIB1054 // DllImport kept, as in WindowsJobObject: LibraryImport would need AllowUnsafeBlocks.
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr GetCurrentProcess();
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool OpenProcessToken(IntPtr process, uint desiredAccess, out SafeAccessTokenHandle token);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateRestrictedToken(SafeAccessTokenHandle existingToken, uint flags, uint disableSidCount,
|
||||
IntPtr sidsToDisable, uint deletePrivilegeCount, IntPtr privilegesToDelete, uint restrictedSidCount, IntPtr sidsToRestrict,
|
||||
out SafeAccessTokenHandle newToken);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetTokenInformation(SafeAccessTokenHandle token, int infoClass, ref TOKEN_DEFAULT_DACL info, uint length);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
private static extern uint SetSecurityInfo(SafeAccessTokenHandle handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
|
||||
IntPtr dacl, IntPtr sacl);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, IntPtr value, IntPtr size,
|
||||
IntPtr previousValue, IntPtr returnSize);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern void DeleteProcThreadAttributeList(IntPtr list);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessAsUserW(SafeAccessTokenHandle token, string applicationName, StringBuilder commandLine,
|
||||
IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandles, uint creationFlags,
|
||||
IntPtr environment, string? currentDirectory, ref STARTUPINFOEX startupInfo, out PROCESS_INFORMATION processInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint ResumeThread(IntPtr thread);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GetProcessWindowStation();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GetThreadDesktop(uint threadId);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern uint GetCurrentThreadId();
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr LocalFree(IntPtr memory);
|
||||
|
||||
[DllImport("advapi32.dll", EntryPoint = "GetSecurityInfo", SetLastError = true)]
|
||||
private static extern uint GetSecurityInfoRaw(IntPtr handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
|
||||
out IntPtr dacl, IntPtr sacl, out IntPtr securityDescriptor);
|
||||
|
||||
[DllImport("advapi32.dll", EntryPoint = "SetSecurityInfo", SetLastError = true)]
|
||||
private static extern uint SetSecurityInfoRaw(IntPtr handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
|
||||
IntPtr dacl, IntPtr sacl);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool TerminateProcess(IntPtr process, uint exitCode);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
#pragma warning restore SYSLIB1054
|
||||
}
|
||||
@@ -29,6 +29,16 @@ internal sealed class WindowsJobObject : IDisposable
|
||||
/// <param name="processMemoryLimitBytes">The per-process commit limit.</param>
|
||||
/// <returns>The job; dispose it to kill the worker.</returns>
|
||||
public static WindowsJobObject Assign(Process process, long processMemoryLimitBytes)
|
||||
=> Assign(process.Handle, processMemoryLimitBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a job with the worker limits and assigns the process behind <paramref name="processHandle"/> to it — the
|
||||
/// restricted launch assigns its worker while it is still suspended (<see cref="RestrictedWorkerLauncher"/>).
|
||||
/// </summary>
|
||||
/// <param name="processHandle">A handle with <c>PROCESS_SET_QUOTA</c> and <c>PROCESS_TERMINATE</c>.</param>
|
||||
/// <param name="processMemoryLimitBytes">The per-process commit limit.</param>
|
||||
/// <returns>The job; dispose it to kill the worker.</returns>
|
||||
public static WindowsJobObject Assign(IntPtr processHandle, long processMemoryLimitBytes)
|
||||
{
|
||||
var handle = CreateJobObjectW(IntPtr.Zero, null);
|
||||
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed");
|
||||
@@ -48,7 +58,7 @@ internal sealed class WindowsJobObject : IDisposable
|
||||
if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, ref info,
|
||||
(uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>()))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed");
|
||||
if (!AssignProcessToJobObject(handle, process.Handle))
|
||||
if (!AssignProcessToJobObject(handle, processHandle))
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
|
||||
return job;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
|
||||
|
||||
/// <summary>
|
||||
/// The text a native Windows process launch takes (<see cref="RestrictedWorkerLauncher"/>): the command line and the
|
||||
/// environment block. Pure string work, so it is testable on any OS.
|
||||
/// </summary>
|
||||
internal static class WindowsProcessText
|
||||
{
|
||||
/// <summary>A Windows command line for <paramref name="fileName"/> and <paramref name="arguments"/> (MSVC quoting rules).</summary>
|
||||
/// <param name="fileName">The executable.</param>
|
||||
/// <param name="arguments">The arguments.</param>
|
||||
/// <returns>The command line.</returns>
|
||||
internal static string BuildCommandLine(string fileName, IEnumerable<string> arguments)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
AppendQuoted(sb, fileName);
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
sb.Append(' ');
|
||||
AppendQuoted(sb, argument);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendQuoted(StringBuilder sb, string argument)
|
||||
{
|
||||
if (argument.Length > 0 && argument.IndexOfAny([' ', '\t', '\n', '\v', '"']) < 0)
|
||||
{
|
||||
sb.Append(argument);
|
||||
return;
|
||||
}
|
||||
sb.Append('"');
|
||||
var backslashes = 0;
|
||||
foreach (var c in argument)
|
||||
{
|
||||
if (c == '\\')
|
||||
{
|
||||
backslashes++;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') sb.Append('\\', (backslashes * 2) + 1);
|
||||
else sb.Append('\\', backslashes);
|
||||
backslashes = 0;
|
||||
sb.Append(c);
|
||||
}
|
||||
sb.Append('\\', backslashes * 2);
|
||||
sb.Append('"');
|
||||
}
|
||||
|
||||
/// <summary>A sorted, double-NUL-terminated UTF-16 environment block.</summary>
|
||||
/// <param name="environment">The variables.</param>
|
||||
/// <returns>The block.</returns>
|
||||
internal static string BuildEnvironmentBlock(IDictionary<string, string?> environment)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var (name, value) in environment.Where(kv => kv.Value is not null).OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
|
||||
sb.Append(name).Append('=').Append(value).Append('\0');
|
||||
if (sb.Length == 0) sb.Append('\0');
|
||||
sb.Append('\0');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,10 @@ internal sealed record BatchSubmission(string Key, EvalRequest Request, TimeSpan
|
||||
/// it, lmxopcua#790), before the worker is killed and the key struck; null = 60 s.
|
||||
/// </param>
|
||||
/// <param name="CompileThreads">The worker's dedicated compile threads; 0 = its default.</param>
|
||||
/// <param name="WorkerSid">
|
||||
/// Windows only: the worker identity's SID (<c>Scripting:Worker:Identity</c>). Set, the worker starts under a restricted
|
||||
/// token (<see cref="RestrictedWorkerLauncher"/>) and the pipe ACL admits it beside the host; null keeps the host's token.
|
||||
/// </param>
|
||||
internal sealed record WorkerLaunchSpec(
|
||||
string Shard,
|
||||
string WorkerPath,
|
||||
@@ -89,7 +93,8 @@ internal sealed record WorkerLaunchSpec(
|
||||
byte[]? PeCacheKey = null,
|
||||
string? MarkerDirectory = null,
|
||||
TimeSpan? LoadTimeout = null,
|
||||
int CompileThreads = 0)
|
||||
int CompileThreads = 0,
|
||||
string? WorkerSid = null)
|
||||
{
|
||||
/// <summary>The default compile timeout.</summary>
|
||||
public static readonly TimeSpan DefaultLoadTimeout = TimeSpan.FromSeconds(60);
|
||||
@@ -119,6 +124,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
|
||||
private readonly Process _process;
|
||||
private readonly NamedPipeServerStream _pipe;
|
||||
private readonly IDisposable? _job;
|
||||
private readonly IDisposable? _stdio;
|
||||
private readonly string _workDir;
|
||||
private readonly ILogger _log;
|
||||
private readonly Action<LogBatch> _onLogs;
|
||||
@@ -143,10 +149,11 @@ internal sealed class WorkerConnection : IAsyncDisposable
|
||||
private long _nextLoadId;
|
||||
private int _disposed;
|
||||
|
||||
private WorkerConnection(string shard, Process process, NamedPipeServerStream pipe, IDisposable? job, string workDir,
|
||||
ILogger log, Action<LogBatch> onLogs, TimeSpan heartbeatInterval, TimeSpan loadTimeout, Action<WorkerExit> onExiting,
|
||||
string? markerPath)
|
||||
private WorkerConnection(string shard, Process process, NamedPipeServerStream pipe, IDisposable? job, IDisposable? stdio,
|
||||
string workDir, ILogger log, Action<LogBatch> onLogs, TimeSpan heartbeatInterval, TimeSpan loadTimeout,
|
||||
Action<WorkerExit> onExiting, string? markerPath)
|
||||
{
|
||||
_stdio = stdio;
|
||||
_loadTimeout = loadTimeout;
|
||||
_onExiting = onExiting;
|
||||
_markerPath = markerPath;
|
||||
@@ -222,36 +229,57 @@ internal sealed class WorkerConnection : IAsyncDisposable
|
||||
var workDir = Path.Combine(Path.GetTempPath(), "otopcua-scriptworker", pipeName);
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
// One instance, current user only: the pipe accepts exactly one connection, from this account.
|
||||
var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
// One instance, admitted principals only: the pipe accepts exactly one connection. Windows: an explicit,
|
||||
// protected ACL — the host account and, under Scripting:Worker:Identity, the worker identity (design §6).
|
||||
// Elsewhere: CurrentUserOnly (a Unix socket owned by this user, 0600).
|
||||
if (spec.WorkerSid is not null && !OperatingSystem.IsWindows())
|
||||
throw new PlatformNotSupportedException("Scripting:Worker:Identity is only supported on Windows");
|
||||
var pipe = OperatingSystem.IsWindows()
|
||||
? WorkerPipeSecurity.CreateServer(pipeName, WorkerPipeSecurity.ForCurrentHost(spec.WorkerSid))
|
||||
: new NamedPipeServerStream(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
Process? process = null;
|
||||
IDisposable? job = null;
|
||||
IDisposable? stdio = null;
|
||||
string? markerPath = null;
|
||||
try
|
||||
{
|
||||
var psi = BuildStartInfo(spec, pipeName, nonce, workDir);
|
||||
process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
if (!process.Start()) throw new InvalidOperationException("the worker process did not start");
|
||||
// Before anything else can fail: a host that dies from here on leaves a marker the next start's orphan
|
||||
// sweep can match to exactly this process (design §3.3).
|
||||
if (spec.MarkerDirectory is { } markerDir) markerPath = WorkerLaunchMarker.Write(markerDir, process, pipeName, log);
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Native and JIT memory backstop above the managed heap limit.
|
||||
job = WindowsJobObject.Assign(process, spec.HeapLimitBytes + 256L * 1024 * 1024);
|
||||
}
|
||||
var shardLabel = spec.Shard;
|
||||
process.ErrorDataReceived += (_, e) =>
|
||||
void OnStderr(string? line)
|
||||
{
|
||||
if (e.Data is { Length: > 0 } line) log.Warning("ScriptWorker[{Shard}] {WorkerStderr}", shardLabel, line);
|
||||
};
|
||||
process.OutputDataReceived += (_, e) =>
|
||||
if (line is { Length: > 0 }) log.Warning("ScriptWorker[{Shard}] {WorkerStderr}", shardLabel, line);
|
||||
}
|
||||
void OnStdout(string? line)
|
||||
{
|
||||
if (e.Data is { Length: > 0 } line) log.Debug("ScriptWorker[{Shard}] stdout {WorkerStdout}", shardLabel, line);
|
||||
};
|
||||
process.BeginErrorReadLine();
|
||||
process.BeginOutputReadLine();
|
||||
if (line is { Length: > 0 }) log.Debug("ScriptWorker[{Shard}] stdout {WorkerStdout}", shardLabel, line);
|
||||
}
|
||||
// Native and JIT memory backstop above the managed heap limit.
|
||||
var jobLimit = spec.HeapLimitBytes + 256L * 1024 * 1024;
|
||||
if (spec.WorkerSid is { } workerSid && OperatingSystem.IsWindows())
|
||||
{
|
||||
// The worker reaches only what its identity may: its working directory is the one place it writes.
|
||||
RestrictedWorkerLauncher.GrantWorkDirectory(workDir, workerSid);
|
||||
var launched = RestrictedWorkerLauncher.Start(psi, workerSid, jobLimit, OnStdout, OnStderr);
|
||||
process = launched.Process;
|
||||
job = launched.Job;
|
||||
stdio = launched;
|
||||
if (spec.MarkerDirectory is { } restrictedMarkerDir)
|
||||
markerPath = WorkerLaunchMarker.Write(restrictedMarkerDir, process, pipeName, log);
|
||||
}
|
||||
else
|
||||
{
|
||||
process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
if (!process.Start()) throw new InvalidOperationException("the worker process did not start");
|
||||
// Before anything else can fail: a host that dies from here on leaves a marker the next start's orphan
|
||||
// sweep can match to exactly this process (design §3.3).
|
||||
if (spec.MarkerDirectory is { } markerDir) markerPath = WorkerLaunchMarker.Write(markerDir, process, pipeName, log);
|
||||
if (OperatingSystem.IsWindows()) job = WindowsJobObject.Assign(process, jobLimit);
|
||||
process.ErrorDataReceived += (_, e) => OnStderr(e.Data);
|
||||
process.OutputDataReceived += (_, e) => OnStdout(e.Data);
|
||||
process.BeginErrorReadLine();
|
||||
process.BeginOutputReadLine();
|
||||
}
|
||||
|
||||
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
handshakeCts.CancelAfter(spec.HandshakeTimeout);
|
||||
@@ -283,7 +311,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
|
||||
else
|
||||
await HandshakeAsync(pipe, hello, nonce, process.Id, handshakeCts.Token).ConfigureAwait(false);
|
||||
|
||||
var connection = new WorkerConnection(spec.Shard, process, pipe, job, workDir, log, onLogs, spec.HeartbeatInterval,
|
||||
var connection = new WorkerConnection(spec.Shard, process, pipe, job, stdio, workDir, log, onLogs, spec.HeartbeatInterval,
|
||||
spec.EffectiveLoadTimeout, onExiting, markerPath);
|
||||
connection.Begin();
|
||||
return connection;
|
||||
@@ -292,6 +320,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
|
||||
{
|
||||
try { if (process is { HasExited: false }) process.Kill(entireProcessTree: true); } catch { /* best effort */ }
|
||||
job?.Dispose();
|
||||
stdio?.Dispose();
|
||||
process?.Dispose();
|
||||
await pipe.DisposeAsync().ConfigureAwait(false);
|
||||
TryDeleteDirectory(workDir);
|
||||
@@ -367,7 +396,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
|
||||
return reply.Ready;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildStartInfo(WorkerLaunchSpec spec, string pipeName, string nonce, string workDir)
|
||||
internal static ProcessStartInfo BuildStartInfo(WorkerLaunchSpec spec, string pipeName, string nonce, string workDir)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
@@ -408,6 +437,15 @@ internal sealed class WorkerConnection : IAsyncDisposable
|
||||
psi.Environment[ScriptWorkerEnvironment.Nonce] = nonce;
|
||||
psi.Environment[ScriptWorkerEnvironment.ParentPid] = Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
psi.Environment[ScriptWorkerEnvironment.Shard] = spec.Shard;
|
||||
if (spec.WorkerSid is not null)
|
||||
{
|
||||
// A restricted worker cannot write the host's temp directory; its own working directory is its temp.
|
||||
psi.Environment["TEMP"] = workDir;
|
||||
psi.Environment["TMP"] = workDir;
|
||||
// No debugger / profiler / diagnostics IPC: a restricted token cannot create the runtime's named objects,
|
||||
// and nothing should attach to a sandboxed worker anyway.
|
||||
psi.Environment["DOTNET_EnableDiagnostics"] = "0";
|
||||
}
|
||||
return psi;
|
||||
}
|
||||
|
||||
@@ -651,6 +689,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
|
||||
_writer.Complete();
|
||||
await _pipe.DisposeAsync().ConfigureAwait(false);
|
||||
_job?.Dispose();
|
||||
_stdio?.Dispose();
|
||||
_process.Dispose();
|
||||
TryDeleteDirectory(_workDir);
|
||||
WorkerLaunchMarker.TryDelete(_markerPath);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
|
||||
|
||||
/// <summary>
|
||||
/// The ScriptWorker pipe's explicit ACL on Windows (lmxopcua#780 design §6, "an explicit ACL when the worker runs as a
|
||||
/// different account"): a protected DACL that admits the host account and — when <c>Scripting:Worker:Identity</c> is
|
||||
/// set — the worker identity, each with read/write, and nobody else (no SYSTEM, no Administrators, no Everyone). The
|
||||
/// owner is the host token's owner, as .NET's <c>PipeOptions.CurrentUserOnly</c> sets it, so the worker's
|
||||
/// <c>CurrentUserOnly</c> client check (pipe owner == its token owner, which a restricted token keeps) still
|
||||
/// authenticates the server end.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class WorkerPipeSecurity
|
||||
{
|
||||
/// <summary>The rights each admitted principal gets: read/write data, attributes and the security descriptor (read).</summary>
|
||||
internal const PipeAccessRights GrantedRights = PipeAccessRights.ReadWrite;
|
||||
|
||||
/// <summary>Builds the pipe ACL for this host process.</summary>
|
||||
/// <param name="workerSid">The worker identity's SID string, or null when the worker keeps the host's token.</param>
|
||||
/// <returns>The pipe security.</returns>
|
||||
public static PipeSecurity ForCurrentHost(string? workerSid)
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var owner = identity.Owner ?? identity.User ?? throw new InvalidOperationException("the host token has no owner");
|
||||
var user = identity.User ?? owner;
|
||||
return Create(owner, user, workerSid is null ? null : new SecurityIdentifier(workerSid));
|
||||
}
|
||||
|
||||
/// <summary>Builds the pipe ACL.</summary>
|
||||
/// <param name="owner">The pipe owner (the host token's owner).</param>
|
||||
/// <param name="hostUser">The host account (its token's user SID).</param>
|
||||
/// <param name="worker">The worker identity, or null.</param>
|
||||
/// <returns>The pipe security: protected, allow-only, one ACE for the host account and one for the worker identity.</returns>
|
||||
public static PipeSecurity Create(SecurityIdentifier owner, SecurityIdentifier hostUser, SecurityIdentifier? worker)
|
||||
{
|
||||
var security = new PipeSecurity();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
security.SetOwner(owner);
|
||||
// The host account and the worker identity, nothing else: not the owner when it differs (an elevated host's
|
||||
// owner is BUILTIN\Administrators — .NET's CurrentUserOnly would grant it; the owner keeps only its implicit
|
||||
// READ_CONTROL / WRITE_DAC).
|
||||
var principals = new List<SecurityIdentifier> { hostUser };
|
||||
if (worker is not null && !principals.Contains(worker)) principals.Add(worker);
|
||||
foreach (var principal in principals)
|
||||
security.AddAccessRule(new PipeAccessRule(principal, GrantedRights, AccessControlType.Allow));
|
||||
return security;
|
||||
}
|
||||
|
||||
/// <summary>Creates the single-instance server end of a worker pipe with <paramref name="security"/>.</summary>
|
||||
/// <param name="pipeName">The random pipe name.</param>
|
||||
/// <param name="security">The ACL (<see cref="ForCurrentHost"/>).</param>
|
||||
/// <returns>The server stream.</returns>
|
||||
public static NamedPipeServerStream CreateServer(string pipeName, PipeSecurity security)
|
||||
=> NamedPipeServerStreamAcl.Create(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous, inBufferSize: 0, outBufferSize: 0, security);
|
||||
}
|
||||
@@ -138,6 +138,17 @@ public sealed class ScriptWorkerOptions
|
||||
/// </summary>
|
||||
public string? ExecutablePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The worker's identity on Windows (design §6, owner decision §14 #5): <c>NT SERVICE\<name></c> — the virtual
|
||||
/// account <c>scripts/install/Install-ScriptWorkerIsolation.ps1</c> registers, by default
|
||||
/// <c>NT SERVICE\OtOpcUaScriptWorker</c> — or a SID string. When set, each worker starts under a restricted token
|
||||
/// derived from the host's (see <see cref="ScriptWorkerIdentity"/>): every privilege removed, and every access check
|
||||
/// must ALSO pass for this identity, <c>Everyone</c>, <c>BUILTIN\Users</c> or <c>RESTRICTED</c>. The pipe ACL then
|
||||
/// admits the host account and this identity only. Null (the default) keeps the host's own token. Windows only:
|
||||
/// startup refuses it elsewhere.
|
||||
/// </summary>
|
||||
public string? Identity { get; set; }
|
||||
|
||||
/// <summary>The hard grace that applies to a run with soft budget <paramref name="softTimeout"/>.</summary>
|
||||
/// <param name="softTimeout">The run's soft deadline <c>T</c>.</param>
|
||||
/// <returns><see cref="HardGrace"/> when set, otherwise <c>max(1 s, T)</c>.</returns>
|
||||
@@ -228,6 +239,7 @@ public sealed class ScriptWorkerOptions
|
||||
IsolationProbationRuns = probation,
|
||||
PeCacheDir = PeCacheDir,
|
||||
ExecutablePath = ExecutablePath,
|
||||
Identity = Identity,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,6 +264,21 @@ public sealed class ScriptWorkerOptions
|
||||
return $"{key} '{PeCacheDir}' is inside the content root '{contentRootPath}'; it must live outside it.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-closed identity validation: <see cref="Identity"/>, when set, must be <c>NT SERVICE\<name></c> or a SID
|
||||
/// string, and the host must run on Windows.
|
||||
/// </summary>
|
||||
/// <param name="isWindows">Whether the host runs on Windows (callers pass <see cref="OperatingSystem.IsWindows"/>).</param>
|
||||
/// <returns>The violation, or null when the identity is unset or acceptable.</returns>
|
||||
public string? ValidateIdentity(bool isWindows)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Identity)) return null;
|
||||
const string key = ScriptingOptions.SectionName + ":Worker:" + nameof(Identity);
|
||||
if (!isWindows)
|
||||
return $"{key} '{Identity}' is only supported on Windows (it is a restricted token); unset it on this host.";
|
||||
return ScriptWorkerIdentity.TryResolveSid(Identity, out _, out var error) ? null : $"{key} '{Identity}': {error}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// The ScriptWorker's Windows identity (<c>Scripting:Worker:Identity</c>, lmxopcua#780 design §6, owner decision §14 #5).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why a restricted token and not a logon.</b> Windows starts a process under a virtual account
|
||||
/// (<c>NT SERVICE\name</c>) only when the Service Control Manager starts that service; a host cannot launch K + 1
|
||||
/// short-lived children, each with its own nonce, pipe and Job Object, that way. A separate local user would need
|
||||
/// a stored password and <c>SeAssignPrimaryTokenPrivilege</c> on the host account. Instead the host derives a
|
||||
/// <b>restricted token</b> from its own (the Windows Service Hardening / browser-sandbox technique): every
|
||||
/// privilege is removed, and the virtual account's SID is one of the token's <i>restricting SIDs</i>, so every
|
||||
/// access check must pass twice — once for the host account and once for the restricting set
|
||||
/// (<see cref="RestrictingSids"/>). The worker can therefore reach only what BOTH the host account AND the worker
|
||||
/// identity (or <c>Everyone</c> / <c>BUILTIN\Users</c> / <c>RESTRICTED</c>, which the runtime needs to load from
|
||||
/// <c>System32</c>) may reach: in effect a standard user bounded by the host account, with no privileges.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The virtual account's SID is computable from its name (<see cref="ServiceSid"/>), so the identity works for ACLs
|
||||
/// and the firewall rule whether or not the installer's anchor service exists; the anchor only makes the name
|
||||
/// resolvable in ACL editors.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static partial class ScriptWorkerIdentity
|
||||
{
|
||||
/// <summary>The installer's default virtual account name (the anchor service's name).</summary>
|
||||
public const string DefaultServiceName = "OtOpcUaScriptWorker";
|
||||
|
||||
/// <summary>The installer's default identity, as configured.</summary>
|
||||
public const string DefaultIdentity = @"NT SERVICE\" + DefaultServiceName;
|
||||
|
||||
/// <summary>Everyone.</summary>
|
||||
public const string EveryoneSid = "S-1-1-0";
|
||||
|
||||
/// <summary>BUILTIN\Users.</summary>
|
||||
public const string BuiltinUsersSid = "S-1-5-32-545";
|
||||
|
||||
/// <summary>NT AUTHORITY\RESTRICTED (the SID many system objects grant for restricted tokens).</summary>
|
||||
public const string RestrictedSid = "S-1-5-12";
|
||||
|
||||
private const string ServicePrefix = @"NT SERVICE\";
|
||||
|
||||
/// <summary>
|
||||
/// The restricting SIDs of a worker token for <paramref name="workerSid"/>: the identity itself, then
|
||||
/// <c>Everyone</c>, <c>BUILTIN\Users</c> and <c>RESTRICTED</c>. <c>Authenticated Users</c> and the logon SID are
|
||||
/// deliberately absent (the first is granted Modify on many data folders; the second reaches the host's own
|
||||
/// processes in an interactive session).
|
||||
/// </summary>
|
||||
/// <param name="workerSid">The worker identity's SID string.</param>
|
||||
/// <returns>The restricting SID strings, identity first.</returns>
|
||||
public static IReadOnlyList<string> RestrictingSids(string workerSid) => [workerSid, EveryoneSid, BuiltinUsersSid, RestrictedSid];
|
||||
|
||||
/// <summary>
|
||||
/// The SID Windows assigns the virtual account <c>NT SERVICE\<paramref name="serviceName"/></c>:
|
||||
/// <c>S-1-5-80-</c> followed by the SHA-1 of the upper-cased name in UTF-16LE, read as five little-endian
|
||||
/// 32-bit values (the algorithm behind <c>sc.exe showsid</c>).
|
||||
/// </summary>
|
||||
/// <param name="serviceName">The service name (no <c>NT SERVICE\</c> prefix).</param>
|
||||
/// <returns>The SID string.</returns>
|
||||
public static string ServiceSid(string serviceName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
|
||||
var hash = SHA1.HashData(Encoding.Unicode.GetBytes(serviceName.ToUpperInvariant()));
|
||||
var sb = new StringBuilder("S-1-5-80");
|
||||
for (var i = 0; i < 5; i++)
|
||||
sb.Append('-').Append(BinaryPrimitives.ReadUInt32LittleEndian(hash.AsSpan(i * 4, 4)).ToString(CultureInfo.InvariantCulture));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Resolves a configured identity to a SID string without touching the OS.</summary>
|
||||
/// <param name="identity"><c>NT SERVICE\name</c> or a SID string (<c>S-1-…</c>).</param>
|
||||
/// <param name="sid">The SID string.</param>
|
||||
/// <param name="error">Why it was refused.</param>
|
||||
/// <returns>True when resolved.</returns>
|
||||
public static bool TryResolveSid(string? identity, out string sid, out string? error)
|
||||
{
|
||||
sid = string.Empty;
|
||||
error = null;
|
||||
var value = identity?.Trim();
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
error = "no identity is set";
|
||||
return false;
|
||||
}
|
||||
if (value.StartsWith(ServicePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var name = value[ServicePrefix.Length..];
|
||||
if (!ServiceNamePattern().IsMatch(name))
|
||||
{
|
||||
error = "the virtual account name must be 1–80 letters, digits, '_', '-' or '.'";
|
||||
return false;
|
||||
}
|
||||
sid = ServiceSid(name);
|
||||
return true;
|
||||
}
|
||||
if (SidPattern().IsMatch(value))
|
||||
{
|
||||
if (value is EveryoneSid or BuiltinUsersSid or RestrictedSid || value.StartsWith("S-1-5-32-", StringComparison.Ordinal)
|
||||
|| value == "S-1-5-18" || value == "S-1-5-11")
|
||||
{
|
||||
error = "a well-known group or SYSTEM is not a worker identity; use a dedicated virtual account";
|
||||
return false;
|
||||
}
|
||||
sid = value.ToUpperInvariant();
|
||||
return true;
|
||||
}
|
||||
error = @"expected 'NT SERVICE\<name>' (the installer's virtual account) or a SID string 'S-1-…'";
|
||||
return false;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[A-Za-z0-9_.\-]{1,80}$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ServiceNamePattern();
|
||||
|
||||
[GeneratedRegex(@"^[Ss]-1-\d+(-\d+){1,14}$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SidPattern();
|
||||
}
|
||||
@@ -68,7 +68,8 @@ public sealed class ScriptRunnerSelection : IScriptRunnerProvider, IAsyncDisposa
|
||||
|
||||
/// <summary>
|
||||
/// Startup checks for the runner options: returns the Warnings to log, and throws when the configuration must
|
||||
/// not start (a <c>PeCacheDir</c> that is relative or inside the content root — fail closed).
|
||||
/// not start (a <c>PeCacheDir</c> that is relative or inside the content root, or a <c>Worker:Identity</c> that
|
||||
/// is malformed or set off Windows — fail closed).
|
||||
/// </summary>
|
||||
/// <param name="options">The bound, sanitized <c>Scripting</c> options.</param>
|
||||
/// <param name="contentRootPath">The host's content root.</param>
|
||||
@@ -82,6 +83,8 @@ public sealed class ScriptRunnerSelection : IScriptRunnerProvider, IAsyncDisposa
|
||||
|
||||
if (options.Worker.ValidatePaths(contentRootPath) is { } violation)
|
||||
throw new InvalidOperationException($"Scripting misconfiguration: {violation}");
|
||||
if (options.Worker.ValidateIdentity(OperatingSystem.IsWindows()) is { } identityViolation)
|
||||
throw new InvalidOperationException($"Scripting misconfiguration: {identityViolation}");
|
||||
|
||||
var mode = $"Scripting:Runner={options.Runner}";
|
||||
var shadow = options.Runner == ScriptRunnerMode.Shadow;
|
||||
|
||||
@@ -536,6 +536,13 @@ internal sealed class WorkerHost
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
// Access denied: a worker under Scripting:Worker:Identity runs with a restricted token that may not open the
|
||||
// host's process. It exists (GetProcessById found it), so it is alive; on Windows the Job Object's
|
||||
// kill-on-close is what ends the worker with its host, and pipe end-of-stream backs it up.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnWriteFault(Exception ex)
|
||||
|
||||
Reference in New Issue
Block a user