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