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