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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user