diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 62e85e8..618a2f2 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -742,7 +742,10 @@ Transport security here applies only to the public gRPC channel. The gateway↔worker link is a per-session **named pipe** (`mxgw-{gatewayPid}-{sessionUid}`), not a network socket. It is not TLS-encrypted and does not need to be: it never leaves the local Windows host and -is secured by the OS pipe ACL. See [Worker Frame Protocol](./WorkerFrameProtocol.md). +is secured by the OS pipe ACL: on Windows the gateway creates each pipe with a +protected DACL that grants only its own account (the account the worker is +launched as) and denies network logons; on the non-Windows test matrix it uses +`PipeOptions.CurrentUserOnly`. See `WorkerPipeSecurity` and [Worker Frame Protocol](./WorkerFrameProtocol.md). ## Related Documentation diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionWorkerClientFactory.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionWorkerClientFactory.cs index ab416d9..7e7401f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionWorkerClientFactory.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionWorkerClientFactory.cs @@ -171,15 +171,30 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory /// The buffer sizes are explicit so the pipe is not created with a zero quota; see /// . On Unix hosts (the macOS test matrix, where named pipes /// are Unix domain sockets) the sizes are advisory — the fix targets Windows production. + /// Access is restricted to the gateway's own account (#187): on Windows by the explicit ACL + /// from , elsewhere by . /// - private static NamedPipeServerStream CreatePipe(string pipeName) + internal static NamedPipeServerStream CreatePipe(string pipeName) { + if (OperatingSystem.IsWindows()) + { + return NamedPipeServerStreamAcl.Create( + pipeName, + PipeDirection.InOut, + maxNumberOfServerInstances: 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous, + inBufferSize: PipeBufferSizeBytes, + outBufferSize: PipeBufferSizeBytes, + WorkerPipeSecurity.CreateForCurrentUser()); + } + return new NamedPipeServerStream( pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1, PipeTransmissionMode.Byte, - PipeOptions.Asynchronous, + WorkerPipeSecurity.UnixPipeOptions, inBufferSize: PipeBufferSizeBytes, outBufferSize: PipeBufferSizeBytes); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/WorkerPipeSecurity.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/WorkerPipeSecurity.cs new file mode 100644 index 0000000..f095ab0 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/WorkerPipeSecurity.cs @@ -0,0 +1,63 @@ +using System.IO.Pipes; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace ZB.MOM.WW.MxGateway.Server.Sessions; + +/// +/// Builds the access control applied to each gateway↔worker named pipe. The pipe carries +/// credential-bearing commands (secured writes, AuthenticateUser, alarm-ack passwords), so +/// its unguessable name is not enough: only the account the gateway runs as — which is also the +/// account the worker is launched as, because the launcher sets no alternate credentials — may +/// open it, and never over the network. +/// +internal static class WorkerPipeSecurity +{ + /// Gets the options every worker pipe is created with on non-Windows hosts. + /// + /// On Unix the pipe is a Unix domain socket; makes it + /// owner-only and rejects peers running as a different user. Windows uses the explicit ACL from + /// instead. + /// + public const PipeOptions UnixPipeOptions = PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly; + + /// Creates the pipe ACL for the account the gateway process runs as. + /// The pipe security descriptor. + [SupportedOSPlatform("windows")] + public static PipeSecurity CreateForCurrentUser() + { + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier user = identity.User + ?? throw new InvalidOperationException("The gateway process identity has no user SID."); + return Create(user); + } + + /// + /// Creates a pipe ACL that grants full control to only, denies network + /// logons, and does not inherit any other entries. + /// + /// The account the gateway and its workers run as. + /// The pipe security descriptor. + [SupportedOSPlatform("windows")] + public static PipeSecurity Create(SecurityIdentifier user) + { + ArgumentNullException.ThrowIfNull(user); + + PipeSecurity security = new(); + + // Drop inherited entries so the default pipe DACL (Everyone/Anonymous read) cannot creep back in. + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.SetOwner(user); + security.AddAccessRule(new PipeAccessRule(user, PipeAccessRights.FullControl, AccessControlType.Allow)); + + // A network logon of the same account still carries the user SID; the deny ACE keeps the + // pipe local-only regardless. + security.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.NetworkSid, domainSid: null), + PipeAccessRights.FullControl, + AccessControlType.Deny)); + + return security; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/WorkerPipeSecurityTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/WorkerPipeSecurityTests.cs new file mode 100644 index 0000000..58b32a2 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/WorkerPipeSecurityTests.cs @@ -0,0 +1,116 @@ +using System.IO.Pipes; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions; + +/// +/// Verifies that the gateway↔worker pipe is restricted to the gateway's own account (#187). +/// The ACL assertions need Windows; the Unix assertion covers the macOS/Linux test matrix. +/// +public sealed class WorkerPipeSecurityTests +{ + + /// Verifies the factory grants only the given user and denies network logons. + [WindowsFact] + [SupportedOSPlatform("windows")] + public void Create_GrantsOnlyTheUserAndDeniesNetwork() + { + SecurityIdentifier user = new("S-1-5-21-1000-2000-3000-1001"); + + PipeSecurity security = WorkerPipeSecurity.Create(user); + + Assert.True(security.AreAccessRulesProtected); + Assert.Equal(user, security.GetOwner(typeof(SecurityIdentifier))); + AssertOnlyUserAllowedAndNetworkDenied(security, user); + } + + /// + /// Verifies the pipe the factory actually creates carries the restricted ACL — read back from + /// the kernel object, not from the descriptor handed to it. + /// + [WindowsFact] + [SupportedOSPlatform("windows")] + public void CreatePipe_OnWindows_AppliesCurrentUserOnlyAcl() + { + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier user = identity.User!; + + using NamedPipeServerStream pipe = SessionWorkerClientFactory.CreatePipe(NewPipeName()); + PipeSecurity applied = pipe.GetAccessControl(); + + AssertOnlyUserAllowedAndNetworkDenied(applied, user); + } + + /// + /// Verifies the pipe still accepts a client running as the same user — the worker is launched + /// under the gateway's own account, so the restriction must not break the session handshake. + /// + [Fact] + public async Task CreatePipe_AcceptsClientRunningAsTheSameUser() + { + string pipeName = NewPipeName(); + using NamedPipeServerStream server = SessionWorkerClientFactory.CreatePipe(pipeName); + using NamedPipeClientStream client = new(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + + Task accept = server.WaitForConnectionAsync(timeout.Token); + await client.ConnectAsync(timeout.Token); + await accept; + + Assert.True(server.IsConnected); + Assert.True(client.IsConnected); + } + + /// + /// Verifies non-Windows pipes are created current-user-only. On Unix .NET enforces this by + /// checking the connecting peer's credentials at accept time (the socket file mode is not + /// changed), so the observable contract is the option itself. + /// + [Fact] + public void UnixPipeOptions_AreAsynchronousAndCurrentUserOnly() + { + Assert.True(WorkerPipeSecurity.UnixPipeOptions.HasFlag(PipeOptions.CurrentUserOnly)); + Assert.True(WorkerPipeSecurity.UnixPipeOptions.HasFlag(PipeOptions.Asynchronous)); + } + + [SupportedOSPlatform("windows")] + private static void AssertOnlyUserAllowedAndNetworkDenied(PipeSecurity security, SecurityIdentifier user) + { + SecurityIdentifier everyoneSid = new(WellKnownSidType.WorldSid, null); + SecurityIdentifier anonymousSid = new(WellKnownSidType.AnonymousSid, null); + SecurityIdentifier networkSid = new(WellKnownSidType.NetworkSid, null); + List rules = security + .GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)) + .Cast() + .ToList(); + + PipeAccessRule[] allows = rules.Where(r => r.AccessControlType == AccessControlType.Allow).ToArray(); + PipeAccessRule allow = Assert.Single(allows); + Assert.Equal(user, allow.IdentityReference); + Assert.Equal(PipeAccessRights.FullControl, allow.PipeAccessRights & PipeAccessRights.FullControl); + + PipeAccessRule deny = Assert.Single(rules, r => r.AccessControlType == AccessControlType.Deny); + Assert.Equal(networkSid, deny.IdentityReference); + + Assert.DoesNotContain(rules, r => r.IdentityReference == everyoneSid && r.AccessControlType == AccessControlType.Allow); + Assert.DoesNotContain(rules, r => r.IdentityReference == anonymousSid && r.AccessControlType == AccessControlType.Allow); + } + + private static string NewPipeName() => $"mxgw-acl-{Guid.NewGuid():N}"; +} + +/// A fact that runs only on Windows. +public sealed class WindowsFactAttribute : FactAttribute +{ + /// Initializes a new instance of the class. + public WindowsFactAttribute() + { + if (!OperatingSystem.IsWindows()) + { + Skip = "Requires Windows."; + } + } +}