fix(sessions): drop the deny-NETWORK pipe ACE (#187)
ci / windows-x86 (push) Successful in 1m26s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m30s
ci / portable (push) Failing after 4m47s

NT AUTHORITY\NETWORK marks a network logon, not a remote caller. On windev
a gateway run from an SSH session carries it in its own token, so the deny
entry locked the gateway's same-machine workers out (UnauthorizedAccess on
connect; 15 fake-worker tests failed). The ACL now holds exactly one entry:
FullControl for the gateway's user.
This commit is contained in:
Joseph Doherty
2026-11-06 15:52:46 -05:00
parent 34a7453ac3
commit 777fef9cad
3 changed files with 21 additions and 22 deletions
+2 -2
View File
@@ -743,8 +743,8 @@ 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: on Windows the gateway creates each pipe with a 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 protected DACL whose only entry grants its own account (the account the worker
launched as) and denies network logons; on the non-Windows test matrix it uses is launched as); on the non-Windows test matrix it uses
`PipeOptions.CurrentUserOnly`. See `WorkerPipeSecurity` and [Worker Frame Protocol](./WorkerFrameProtocol.md). `PipeOptions.CurrentUserOnly`. See `WorkerPipeSecurity` and [Worker Frame Protocol](./WorkerFrameProtocol.md).
## Related Documentation ## Related Documentation
@@ -10,8 +10,13 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
/// credential-bearing commands (secured writes, <c>AuthenticateUser</c>, alarm-ack passwords), so /// 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 /// 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 /// account the worker is launched as, because the launcher sets no alternate credentials — may
/// open it, and never over the network. /// open it.
/// </summary> /// </summary>
/// <remarks>
/// There is deliberately no deny entry for <c>NT AUTHORITY\NETWORK</c>. That SID marks a network
/// <em>logon</em>, not a remote caller: a gateway started from an SSH or WinRM session carries it in
/// its own token, so a deny entry locked its own same-machine workers out (found on windev, #187).
/// </remarks>
internal static class WorkerPipeSecurity internal static class WorkerPipeSecurity
{ {
/// <summary>Gets the options every worker pipe is created with on non-Windows hosts.</summary> /// <summary>Gets the options every worker pipe is created with on non-Windows hosts.</summary>
@@ -34,8 +39,8 @@ internal static class WorkerPipeSecurity
} }
/// <summary> /// <summary>
/// Creates a pipe ACL that grants full control to <paramref name="user"/> only, denies network /// Creates a pipe ACL that grants full control to <paramref name="user"/> only and does not
/// logons, and does not inherit any other entries. /// inherit any other entries.
/// </summary> /// </summary>
/// <param name="user">The account the gateway and its workers run as.</param> /// <param name="user">The account the gateway and its workers run as.</param>
/// <returns>The pipe security descriptor.</returns> /// <returns>The pipe security descriptor.</returns>
@@ -51,12 +56,6 @@ internal static class WorkerPipeSecurity
security.SetOwner(user); security.SetOwner(user);
security.AddAccessRule(new PipeAccessRule(user, PipeAccessRights.FullControl, AccessControlType.Allow)); 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; return security;
} }
@@ -13,10 +13,10 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
public sealed class WorkerPipeSecurityTests public sealed class WorkerPipeSecurityTests
{ {
/// <summary>Verifies the factory grants only the given user and denies network logons.</summary> /// <summary>Verifies the factory grants only the given user.</summary>
[WindowsFact] [WindowsFact]
[SupportedOSPlatform("windows")] [SupportedOSPlatform("windows")]
public void Create_GrantsOnlyTheUserAndDeniesNetwork() public void Create_GrantsOnlyTheUser()
{ {
SecurityIdentifier user = new("S-1-5-21-1000-2000-3000-1001"); SecurityIdentifier user = new("S-1-5-21-1000-2000-3000-1001");
@@ -24,7 +24,7 @@ public sealed class WorkerPipeSecurityTests
Assert.True(security.AreAccessRulesProtected); Assert.True(security.AreAccessRulesProtected);
Assert.Equal(user, security.GetOwner(typeof(SecurityIdentifier))); Assert.Equal(user, security.GetOwner(typeof(SecurityIdentifier)));
AssertOnlyUserAllowedAndNetworkDenied(security, user); AssertOnlyUserAllowed(security, user);
} }
/// <summary> /// <summary>
@@ -41,12 +41,14 @@ public sealed class WorkerPipeSecurityTests
using NamedPipeServerStream pipe = SessionWorkerClientFactory.CreatePipe(NewPipeName()); using NamedPipeServerStream pipe = SessionWorkerClientFactory.CreatePipe(NewPipeName());
PipeSecurity applied = pipe.GetAccessControl(); PipeSecurity applied = pipe.GetAccessControl();
AssertOnlyUserAllowedAndNetworkDenied(applied, user); AssertOnlyUserAllowed(applied, user);
} }
/// <summary> /// <summary>
/// Verifies the pipe still accepts a client running as the same user — the worker is launched /// 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. /// under the gateway's own account, so the restriction must not break the session handshake.
/// On windev this runs from an SSH (network-logon) session, which is what caught the earlier
/// deny-NETWORK entry.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task CreatePipe_AcceptsClientRunningAsTheSameUser() public async Task CreatePipe_AcceptsClientRunningAsTheSameUser()
@@ -77,24 +79,22 @@ public sealed class WorkerPipeSecurityTests
} }
[SupportedOSPlatform("windows")] [SupportedOSPlatform("windows")]
private static void AssertOnlyUserAllowedAndNetworkDenied(PipeSecurity security, SecurityIdentifier user) private static void AssertOnlyUserAllowed(PipeSecurity security, SecurityIdentifier user)
{ {
SecurityIdentifier everyoneSid = new(WellKnownSidType.WorldSid, null); SecurityIdentifier everyoneSid = new(WellKnownSidType.WorldSid, null);
SecurityIdentifier anonymousSid = new(WellKnownSidType.AnonymousSid, null); SecurityIdentifier anonymousSid = new(WellKnownSidType.AnonymousSid, null);
SecurityIdentifier networkSid = new(WellKnownSidType.NetworkSid, null);
List<PipeAccessRule> rules = security List<PipeAccessRule> rules = security
.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)) .GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier))
.Cast<PipeAccessRule>() .Cast<PipeAccessRule>()
.ToList(); .ToList();
PipeAccessRule[] allows = rules.Where(r => r.AccessControlType == AccessControlType.Allow).ToArray(); // Exactly one entry: an allow for the gateway's user. In particular no deny entry — a deny
PipeAccessRule allow = Assert.Single(allows); // for NETWORK locked out same-machine workers when the gateway ran from an SSH logon.
PipeAccessRule allow = Assert.Single(rules);
Assert.Equal(AccessControlType.Allow, allow.AccessControlType);
Assert.Equal(user, allow.IdentityReference); Assert.Equal(user, allow.IdentityReference);
Assert.Equal(PipeAccessRights.FullControl, allow.PipeAccessRights & PipeAccessRights.FullControl); 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 == everyoneSid && r.AccessControlType == AccessControlType.Allow);
Assert.DoesNotContain(rules, r => r.IdentityReference == anonymousSid && r.AccessControlType == AccessControlType.Allow); Assert.DoesNotContain(rules, r => r.IdentityReference == anonymousSid && r.AccessControlType == AccessControlType.Allow);
} }