fix(dashboard): close AttachEventsAsync re-entrancy window; pin ACL decision-table corners (SEC-25 review)
Follow-up to the per-session event ACL. Part of that change rode into 693a78d
via a concurrent agent's pathspec-less commit; this commit carries the review
fixes and uses pathspecs on the commit itself so it cannot recur in either
direction.
Gating the page's subscribe seam made AttachEvents asynchronous — it awaits the
authentication state — and that await is a suspension point the synchronous
version did not have. On a rapid A -> B navigation the suspended A continuation
resumes after B's parameter set has run to completion, re-reads the live
SessionId (now B's), and attaches B a SECOND time. The ACL is not bypassed —
the newer attach already cleared that same session — but the fields holding B's
first subscription are overwritten in place, so nothing ever disposes it: its
EventsHubViewerRegistry entry is never released, which keeps the mirror cloning
events for a session the page is no longer watching through that handle, and
its pump is never cancelled. A resource leak the ACL work introduced.
OnParametersSetAsync now claims a monotonic _attachGeneration synchronously,
before its first await, and AttachEventsAsync re-checks it after the await and
before any field write or Subscribe call. A stale attach returns rather than
detaching: it owns nothing, and tearing down there would destroy the newer
attach's subscription. DetachEventsAsync needs no such guard — it captures and
nulls the live fields synchronously before it awaits, so a resumed detach only
unwinds what it already took ownership of. Same dispatcher-owned identity idea
as the existing ReferenceEquals guards in PumpEventsAsync and
MarkDisconnectedAsync, one level up.
The interleaving is not expressible with the static HtmlRenderer idiom the other
page tests use: it renders a root component once and exposes no parameter-update
seam. The new test therefore adds a minimal Renderer subclass whose only job is
to mount a component and drive a second SetParametersAsync into it while the
first is parked on a gated AuthenticationStateProvider. That subclass is the
lone reason for a narrowly scoped BL0006 suppression, justified in place: it is
test-only scaffolding that never ships, and the cost of the warning coming true
is a compile break in one test file on an SDK bump. Confirmed non-vacuous by
mutation — with the generation check disabled the test goes red on the doubled
subscription and the two passing ACL tests stay green.
Two decision-table corners are now pinned rather than implied. Admin x
nonexistent session id resolves to ALLOW, because the admin bypass is evaluated
before the registry lookup; a plausible "look the session up first, it reads
better" refactor would flip it, so a test documents the ordering. EventsHub's
remarks said "an unknown session id is denied" without qualification, which read
as universal; they now state that the bypass is checked first and every rule
below it is a non-Admin rule.
HubTokenServiceTests gains the truly-absent-field case: a hand-built payload
JSON with no Tags key at all, protected through the same purpose, which is the
shape every in-flight token has across the deploy that introduces the field. The
existing test covered present-but-empty, which does not exercise the null
coalesce that stands between a legacy token and a crash on the hub auth path.
ProtectorPurpose became internal so the test cannot drift from the real purpose
string.
Tag-count cardinality cap considered and recorded as a deliberate non-goal.
Build 0 warnings / 0 errors; 48 filtered (ACL/hub/token/page) and 257 dashboard
tests pass.
This commit is contained in:
@@ -185,6 +185,16 @@ else
|
||||
// AttachEventsAsync is the only writer, and it writes on the renderer's dispatcher.
|
||||
private bool _eventsAuthorized = true;
|
||||
private string? _subscribedSessionId;
|
||||
// Identifies the attach currently entitled to publish subscription state. Bumped
|
||||
// synchronously by OnParametersSetAsync before it awaits anything, so a suspended
|
||||
// AttachEventsAsync continuation can tell that a newer parameter set overtook it — the
|
||||
// same dispatcher-owned identity idea as the ReferenceEquals guards in PumpEventsAsync
|
||||
// and MarkDisconnectedAsync, one level up. Without it, the await on the authentication
|
||||
// state opens a window in which a rapid A -> B navigation lets the stale continuation
|
||||
// re-read the live SessionId and attach B a second time, orphaning B's first
|
||||
// subscription (never disposed, its viewer registration never released, its pump never
|
||||
// cancelled) behind the fields it overwrites.
|
||||
private int _attachGeneration;
|
||||
private readonly LinkedList<MxEvent> _recentEvents = new();
|
||||
|
||||
private bool CanManage { get; set; }
|
||||
@@ -208,11 +218,17 @@ else
|
||||
{
|
||||
if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal))
|
||||
{
|
||||
// Claimed before the first await, so every attach that follows carries a token
|
||||
// that a later parameter set can invalidate. DetachEventsAsync needs no such
|
||||
// guard: it captures and nulls the live fields synchronously before it awaits,
|
||||
// so a resumed detach only unwinds what it already took ownership of.
|
||||
int generation = ++_attachGeneration;
|
||||
|
||||
// Deliberately no ConfigureAwait(false): the resumption must stay on the
|
||||
// renderer's dispatcher so the new subscription is published to
|
||||
// _eventSubscription from the same thread the pump's guard reads it on.
|
||||
await DetachEventsAsync();
|
||||
await AttachEventsAsync();
|
||||
await AttachEventsAsync(generation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +318,7 @@ else
|
||||
// whether a subscription is created at all — the generation guards, the pump, and the detach
|
||||
// coupling below it are untouched, so a denied page holds no subscription to leak and never
|
||||
// registers a viewer, which keeps the broadcaster's mirror off for that session.
|
||||
private async Task AttachEventsAsync()
|
||||
private async Task AttachEventsAsync(int generation)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SessionId))
|
||||
{
|
||||
@@ -313,6 +329,19 @@ else
|
||||
// land back on the renderer's dispatcher, which is where the fields below are owned.
|
||||
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
|
||||
// Checked before ANY field write and before Subscribe, because both are the damage: a
|
||||
// newer parameter set may have run start-to-finish while this continuation was parked,
|
||||
// and SessionId now reads as ITS session. Attaching here would not bypass the ACL (the
|
||||
// newer attach already cleared the same session), but it would strand the live
|
||||
// subscription — overwritten in place, so nothing ever disposes it or releases its
|
||||
// viewer registration, and the mirror stays on for a session nobody is watching. A
|
||||
// stale attach owns nothing, so it returns rather than detaching: tearing down here
|
||||
// would destroy the newer attach's subscription.
|
||||
if (generation != _attachGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_subscribedSessionId = SessionId;
|
||||
_eventsAuthorized = SessionAcl.CanViewSession(authenticationState.User, SessionId);
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// </remarks>
|
||||
public sealed class HubTokenService
|
||||
{
|
||||
private const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
|
||||
// Internal rather than private so a test can protect a hand-built payload through the same
|
||||
// purpose and assert how Validate reads a payload shape this class no longer mints (a token
|
||||
// predating the Tags field). Copying the literal into the test instead would let the two
|
||||
// drift and silently turn that test into an assertion about an unrelated protector.
|
||||
internal const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
|
||||
|
||||
// Hub bearer tokens are single-purpose, data-protection-encrypted, and NOT server-side
|
||||
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy
|
||||
|
||||
@@ -40,9 +40,14 @@ public sealed class EventsHub(
|
||||
/// only checks that the caller carries one of the dashboard roles, which by
|
||||
/// itself would let any Viewer subscribe to any session id they name. The
|
||||
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s
|
||||
/// (SEC-25 / TST-15): Administrators see every session, a Viewer sees a
|
||||
/// session only when its tags intersect their granted tags, and an unknown
|
||||
/// session id is denied. A denied caller is not joined to the group and is
|
||||
/// (SEC-25 / TST-15). The admin bypass is evaluated first, so an
|
||||
/// Administrator joins any session id they name; every check below it
|
||||
/// applies to non-Admin callers only. For those: a Viewer sees a session
|
||||
/// only when its tags intersect their granted tags, an untagged session
|
||||
/// follows <c>Dashboard:UntaggedSessionVisibility</c>, and a session id the
|
||||
/// registry does not have is denied outright — the phantom-id denial is
|
||||
/// therefore a non-Admin rule, not a universal one.
|
||||
/// A denied caller is not joined to the group and is
|
||||
/// not registered with <see cref="EventsHubViewerRegistry"/>, so the mirror
|
||||
/// stays off for a session nobody is legitimately watching. The same ACL
|
||||
/// gates the in-process seam used by the session-details page, so neither
|
||||
|
||||
Reference in New Issue
Block a user