fix(security): resolve the event-subscribe gate's source through IsHandleInNamespace

SubscribeToEvents' sourceId is the manager HANDLE, not a NodeState — the same
object-typed trap as ContinuationPoint.NodeToBrowse, found the same way. The
`sourceId as NodeState` cast compiled, always yielded null, and silently
disabled the gate: an ungranted session could subscribe to an equipment's event
notifier and receive every alarm it raises, including the condition fields,
while every value on it was refused.

Now uses the SDK's own IsHandleInNamespace, which cannot be wrong about the type.

The new test passes a null monitored item deliberately: the gate must deny
without touching the item, which also makes the test a discriminator rather than
a restatement — against the broken cast the call falls through to the base
implementation, which does dereference it, so the test fails loudly.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 09:42:32 -04:00
parent ce404df970
commit 8cdbcb9823
2 changed files with 46 additions and 1 deletions
@@ -2877,9 +2877,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
IEventMonitoredItem monitoredItem,
bool unsubscribe)
{
// ⚠️ sourceId is the MANAGER HANDLE, not a NodeState — the same `object`-typed trap as
// ContinuationPoint.NodeToBrowse. Casting it to NodeState compiles, always yields null, and
// silently disables this gate; IsHandleInNamespace is the SDK's own resolver and cannot be
// wrong about it.
//
// Only creation is gated. Refusing an UNSUBSCRIBE would strand the item on a node the session can
// no longer reach — the client could never detach it, and the server would keep delivering.
if (AclEnforced && !unsubscribe && sourceId is NodeState source
if (AclEnforced && !unsubscribe
&& IsHandleInNamespace(sourceId) is { } source
&& !Permits(IdentityOf(context), OpcUaOperation.CreateMonitoredItems, source.NodeId))
{
return StatusCodes.BadUserAccessDenied;