fix(sessions): restrict the worker named pipe to the gateway's account (#187)
On Windows each worker pipe is created via NamedPipeServerStreamAcl with a protected DACL granting FullControl only to the gateway process user (the worker is launched under the same account) and denying NETWORK logons. On non-Windows hosts (test matrix) the pipe uses PipeOptions.CurrentUserOnly. Tests assert the factory ACL and the ACL read back from the created pipe (Windows), and that a same-user client still connects.
This commit is contained in:
@@ -742,7 +742,10 @@ Transport security here applies only to the public gRPC channel. The
|
|||||||
gateway↔worker link is a per-session **named pipe**
|
gateway↔worker link is a per-session **named pipe**
|
||||||
(`mxgw-{gatewayPid}-{sessionUid}`), not a network socket. It is not
|
(`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
|
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
|
## Related Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// The buffer sizes are explicit so the pipe is not created with a zero quota; see
|
||||||
/// <see cref="PipeBufferSizeBytes"/>. On Unix hosts (the macOS test matrix, where named pipes
|
/// <see cref="PipeBufferSizeBytes"/>. On Unix hosts (the macOS test matrix, where named pipes
|
||||||
/// are Unix domain sockets) the sizes are advisory — the fix targets Windows production.
|
/// 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 <see cref="WorkerPipeSecurity"/>, elsewhere by <see cref="PipeOptions.CurrentUserOnly"/>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
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(
|
return new NamedPipeServerStream(
|
||||||
pipeName,
|
pipeName,
|
||||||
PipeDirection.InOut,
|
PipeDirection.InOut,
|
||||||
maxNumberOfServerInstances: 1,
|
maxNumberOfServerInstances: 1,
|
||||||
PipeTransmissionMode.Byte,
|
PipeTransmissionMode.Byte,
|
||||||
PipeOptions.Asynchronous,
|
WorkerPipeSecurity.UnixPipeOptions,
|
||||||
inBufferSize: PipeBufferSizeBytes,
|
inBufferSize: PipeBufferSizeBytes,
|
||||||
outBufferSize: PipeBufferSizeBytes);
|
outBufferSize: PipeBufferSizeBytes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the access control applied to each gateway↔worker named pipe. The pipe carries
|
||||||
|
/// credential-bearing commands (secured writes, <c>AuthenticateUser</c>, 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WorkerPipeSecurity
|
||||||
|
{
|
||||||
|
/// <summary>Gets the options every worker pipe is created with on non-Windows hosts.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// On Unix the pipe is a Unix domain socket; <see cref="PipeOptions.CurrentUserOnly"/> makes it
|
||||||
|
/// owner-only and rejects peers running as a different user. Windows uses the explicit ACL from
|
||||||
|
/// <see cref="Create(SecurityIdentifier)"/> instead.
|
||||||
|
/// </remarks>
|
||||||
|
public const PipeOptions UnixPipeOptions = PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly;
|
||||||
|
|
||||||
|
/// <summary>Creates the pipe ACL for the account the gateway process runs as.</summary>
|
||||||
|
/// <returns>The pipe security descriptor.</returns>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe ACL that grants full control to <paramref name="user"/> only, denies network
|
||||||
|
/// logons, and does not inherit any other entries.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The account the gateway and its workers run as.</param>
|
||||||
|
/// <returns>The pipe security descriptor.</returns>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkerPipeSecurityTests
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>Verifies the factory grants only the given user and denies network logons.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the pipe the factory actually creates carries the restricted ACL — read back from
|
||||||
|
/// the kernel object, not from the descriptor handed to it.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<PipeAccessRule> rules = security
|
||||||
|
.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier))
|
||||||
|
.Cast<PipeAccessRule>()
|
||||||
|
.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}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A fact that runs only on Windows.</summary>
|
||||||
|
public sealed class WindowsFactAttribute : FactAttribute
|
||||||
|
{
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="WindowsFactAttribute"/> class.</summary>
|
||||||
|
public WindowsFactAttribute()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
Skip = "Requires Windows.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user