feat(scripting): ScriptWorker isolation on Windows: restricted worker identity, pipe ACL, firewall + PeCacheDir installer (lmxopcua#780)
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:
Joseph Doherty
2026-11-10 08:46:23 -05:00
parent 0e96dd08a2
commit 2a8e2981fe
20 changed files with 1898 additions and 37 deletions
@@ -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);