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.";
+ }
+ }
+}