Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsJobObject.cs
T
Joseph Doherty 2a8e2981fe
v2-ci / unit-tests (push) Has been cancelled
feat(scripting): ScriptWorker isolation on Windows: restricted worker identity, pipe ACL, firewall + PeCacheDir installer (lmxopcua#780)
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
2026-11-10 08:46:23 -05:00

137 lines
6.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Microsoft.Win32.SafeHandles;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
/// <summary>
/// A Windows Job Object holding one ScriptWorker (design §3.3, §5.2): <c>KILL_ON_JOB_CLOSE</c> so the kernel kills
/// the worker when the host dies (its handle closes with it), <c>ACTIVE_PROCESS = 1</c> so the worker can start no
/// child process, and a process-memory backstop slightly above the managed heap limit for native and JIT memory.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class WindowsJobObject : IDisposable
{
private const int JobObjectExtendedLimitInformation = 9;
private const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008;
private const uint JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x00000100;
private const uint JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION = 0x00000400;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
private readonly SafeFileHandle _handle;
private WindowsJobObject(SafeFileHandle handle) => _handle = handle;
/// <summary>Creates a job with the worker limits and assigns <paramref name="process"/> to it.</summary>
/// <param name="process">The freshly started worker.</param>
/// <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");
var job = new WindowsJobObject(handle);
try
{
var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
{
LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS
| JOB_OBJECT_LIMIT_PROCESS_MEMORY | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION,
ActiveProcessLimit = 1,
},
ProcessMemoryLimit = (UIntPtr)(ulong)processMemoryLimitBytes,
};
if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, ref info,
(uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>()))
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed");
if (!AssignProcessToJobObject(handle, processHandle))
throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
return job;
}
catch
{
job.Dispose();
throw;
}
}
/// <summary>
/// Kills every process in the job at once (lmxopcua#789). The job admits exactly one process, so this is the
/// worker's whole tree without <c>Process.Kill(entireProcessTree: true)</c>'s system-wide process snapshot,
/// which cost about 100–200 ms of the Windows kill latency.
/// </summary>
/// <returns>False when the job could not be terminated (the caller falls back to a process kill).</returns>
public bool Terminate() => !_handle.IsClosed && TerminateJobObject(_handle, unchecked((uint)-1));
/// <summary>Closes the job handle, which kills every process still in it.</summary>
public void Dispose() => _handle.Dispose();
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize;
public UIntPtr MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public UIntPtr ProcessMemoryLimit;
public UIntPtr JobMemoryLimit;
public UIntPtr PeakProcessMemoryUsed;
public UIntPtr PeakJobMemoryUsed;
}
#pragma warning disable SYSLIB1054 // DllImport kept: LibraryImport would need AllowUnsafeBlocks for four calls.
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern SafeFileHandle CreateJobObjectW(IntPtr securityAttributes, string? name);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject(SafeFileHandle job, int infoClass,
ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION info, uint length);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode);
#pragma warning restore SYSLIB1054
}