Files
mxaccessgw/archreview/2026-07-12/40-security-dashboard.md
T
Joseph Doherty 1a5a12a416
ci / java (push) Successful in 1m54s
ci / portable (push) Successful in 7m6s
docs(archreview): 2026-07-12 follow-up review + remediation designs
Six-domain re-review at the P2 merge (4f5371f): all prior Done claims
verified (none false, 10 partial), 47 new findings (1 High, 14 Medium),
with per-finding design/implementation plans and a new tracking register.
2026-07-13 00:18:38 -04:00

22 KiB

Security & Dashboard — Re-review (Post-Remediation)

Date: 2026-07-12 · Commit: 4f5371f (main) · Baseline: 59856b8 (pre-remediation) · Method: static review on the macOS working tree (no builds, no runtime probes, no source modifications). Every claim cites a path:line read at HEAD. Prior findings are the SEC-NN IDs from archreview/remediation/40-security-dashboard.md; new findings continue from SEC-31.

Prior-finding verification

ID Claimed Verified? Evidence (file:line at HEAD) Notes
SEC-01 Done Partial Defaults now SpecialFolder-derived: Configuration/AuthenticationOptions.cs:16-19, Configuration/TlsOptions.cs:18-22. Rooting enforced: GatewayOptionsValidator.cs:110-113 (SqlitePath), :442-445 (SelfSignedCertPath). Hygiene test: src/ZB.MOM.WW.MxGateway.Tests/ProjectStructure/GatewayTreeHygieneTests.cs:12-27. Stray tracked-tree DB deleted (no *.db under src/ outside bin/). Two residuals: (a) IsRootedForAnyPlatform (GatewayOptionsValidator.cs:541-560) deliberately passes Windows-rooted paths on Unix, so the shipped appsettings.json still materializes a literal C:\ProgramData\... file at runtime on macOS — a fresh stray auth DB dated 2026-07-09 (post-fix) exists at src/ZB.MOM.WW.MxGateway.Tests/bin/Debug/net10.0/C:\ProgramData\MxGateway\gateway-auth.db (gitignored; excluded by the hygiene test's bin/obj filter, GatewayTreeHygieneTests.cs:30-35). (b) Galaxy SnapshotCachePath (appsettings.json:80) has no rooting validation — the Galaxy section is not covered by GatewayOptionsValidator. → SEC-33
SEC-02 Done Yes Dashboard/DashboardAuthorizationHandler.cs:39-53: both the Mode==Disabled and loopback bypasses fire only when requirement.RequiredRoles.Contains(DashboardRoles.Viewer); AdminOnly is [Admin] only (Dashboard/DashboardAuthorizationRequirement.cs:19-20), so bypasses can never satisfy it. Enforcement is at the policy layer as designed; forwarded-headers caveat is documented in the remarks (:21-24).
SEC-03 Done Yes Dashboard/DashboardAuthenticationDefaults.cs:45,56 (plain + __Host- names); Dashboard/DashboardServiceCollectionExtensions.cs:119-142: explicit CookieName override wins, else __Host-MxGatewayDashboard only when SecurePolicy==Always (RequireHttpsCookie default true), else plain name. Guard against applying __Host- without Secure is enforced (:138-141). Residual (unchanged SEC-23): an explicit CookieName starting __Host- combined with RequireHttpsCookie=false is still accepted with no validator diagnosis (:134-137 applies it unconditionally).
SEC-04 Done Yes Configuration/GatewayOptionsValidator.cs:314-318 (Production + DisableLogin → validation error, aborts startup); env threading via ctor :23-27 and Configuration/GatewayConfigurationServiceCollectionExtensions.cs:24 (TryAdd fallback = Development, real host wins). Guard fires only for the exact Production environment name — see SEC-35.
SEC-05 Done Yes Dashboard/HubTokenService.cs:37 (TokenLifetime = 5 min, rationale in comment :30-36); query-string/no-request-logging contract documented at Dashboard/HubTokenAuthenticationHandler.cs:13-22. Tokens remain irrevocable within the 5-minute window and still travel in ?access_token= (HubTokenAuthenticationHandler.cs:69-71) — the accepted design; jti revocation deferred until per-session ACLs.
SEC-06 Done Partial Production hard-stop verified: GatewayOptionsValidator.cs:161-165 (Transport==None in Production → startup error). Docs: docs/GatewayConfiguration.md:244-248 (env-var override MxGateway__Ldap__ServiceAccountPassword documented). The committed dev service-account password is still in the repo at appsettings.json:29 (Ldap.ServiceAccountPassword) and has not been rotated — the doc itself says it "should be rotated". The transport guard shipped; the credential-removal/rotation half of the remediation did not. → SEC-36
SEC-07 Done Yes Security/Authorization/GatewayGrpcScopeResolver.cs:23 (QueryActiveAlarmsRequest => GatewayScopes.EventsRead); both tests now construct the real type (Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs:330,350).
SEC-08 Done Yes Security/Authentication/CachingApiKeyVerifier.cs (15 s success-only TTL cache keyed on SHA-256 of the presented token, :96-120; only successes cached :110-117); Security/Authentication/CoalescingMarkApiKeyStore.cs:76-113 (≤1 last_used write/key/60 s); wired as decorators in Security/Authentication/AuthStoreServiceCollectionExtensions.cs:89-98; invalidation on dashboard revoke/rotate/delete at Dashboard/DashboardApiKeyManagementService.cs:104,144,190; per-call constraints JSON deserialize removed via blob cache (Security/Authentication/GatewayApiKeyIdentityMapper.cs:22-45). Tests exist (Tests/Security/Authentication/CachingApiKeyVerifierTests.cs). New surface reviewed in depth — see SEC-34 (staleness/race, bounded) below.
SEC-10 Done Yes CLI: --expires parsed as relative <N>d/<N>h or absolute ISO-8601 with `AssumeUniversal AdjustToUniversal (Security/Authentication/ApiKeyAdminCommandLineParser.cs:242-274), threaded into CreateKeyAsync (:85-117). Dashboard: DashboardApiKeySummary.cs:13 (ExpiresUtc), snapshot projection Dashboard/DashboardSnapshotService.cs:277, badge logic compares against DateTimeOffset.UtcNow with a 7-day "Expiring" warn window (Dashboard/Components/Pages/ApiKeysPage.razor:474-497; StatusBadge.razor:12-13). Verifier-side rejection is in the shared ZB.MOM.WW.Auth.ApiKeys0.1.4 (documenteddocs/Authentication.md:64-65`; not readable in this repo).
SEC-11 Done Yes, with new defects Login: fixed-window per-remote-IP limiter policy (Dashboard/DashboardEndpointRouteBuilderExtensions.cs:27-41), applied to POST /auth/login (:83), registered + 429 (GatewayApplication.cs:111-126), middleware in pipeline (GatewayApplication.cs:46), test Tests/Gateway/Dashboard/DashboardLoginRateLimitTests.cs. gRPC: Security/Authorization/ApiKeyFailureLimiter.cs checked before the store read (GatewayGrpcAuthorizationInterceptor.cs:72-77), failure recorded :89, reset on success :97; interceptor test asserts the short-circuit (GatewayGrpcAuthorizationInterceptorTests.cs:395-402). The limiter exists and is enforced, but its key-id partitioning creates an unauthenticated lockout DoS (SEC-31) and its LRU eviction is flushable (SEC-32).
SEC-12 Done Yes Dashboard/DashboardSessionAdminService.cs: canonical AuditEvents dashboard-close-session/dashboard-kill-worker (:37-40), written on Denied (:65,147), Success (:89-96,171-178), and every Failure arm (:105,116,132,187,198,214), category SessionAdmin, actor/remote/correlation captured (:238-261), via IAuditWriter (same store as API-key events). Audit-event completeness is good: denied, not-found, faulted, and unexpected paths all emit.
SEC-20 Done Yes Metrics/GatewayMetrics.cs:374_heartbeatFailuresCounter.Add(1) with no session_id tag (rationale comment :370-373). The in-memory per-session map remains dashboard-only.
SEC-25 Done Yes (mitigation, ACL still deferred as documented) Dashboard/Hubs/DashboardEventBroadcaster.cs:39,80-92: when ShowTagValues=false (default, appsettings.json:65) a deep clone is redacted — top-level MxEvent.value (field 5, the only value carrier incl. buffered arrays, Protos/mxaccess_gateway.proto:704-733) plus alarm CurrentValue/LimitValue; source event never mutated (shared with gRPC/replay). EventsHub.cs:42 keeps the TODO(per-session-acl) tied to roadmap item 12. Redaction is complete for the wire shape at HEAD (verified against the proto: event bodies are discriminators; values ride in field 5 + the alarm body).
SEC-30 Done Yes docs/Diagnostics.md:126 — explicit "Not yet implemented" callout; wiring deferred until SEC-13 lands. GatewayLogRedactor helpers still have no call sites.
SEC-09 Done Yes docs/GatewayDashboardDesign.md:424-427 (canonical Administrator role string clarified); live appsettings.json:66-69 uses Administrator/Viewer.
SEC-22 Done Yes docs/Authentication.md rewritten consumer-side: no ApiKeyParser/SqliteApiKeyStore references remain (grep-verified); verification flow documents the shared-library pipeline incl. expiry (:64-65) and the CLI table (:219-220).
SEC-13 Not started Confirmed open Diagnostics/GatewayLogRedactor.cs:12-16 still lists only AuthenticateUser/WriteSecured/WriteSecured2; WriteSecuredBulk/WriteSecured2Bulk exist as command kinds (GatewayGrpcScopeResolver.cs:44-45). Still latent (no call sites).
SEC-14 Not started Confirmed open GatewayApplication.cs:220-221 maps MapZbHealth()/MapZbMetrics() with no RequireAuthorization. Leak severity reduced by SEC-20 (no session ids in exported metrics anymore).
SEC-15 Not started (accepted) Confirmed open GET /logout skips antiforgery, intentional (Dashboard/DashboardEndpointRouteBuilderExtensions.cs:92-99). Accepted risk; unchanged.
SEC-16 Not started Confirmed open Program.cs:37-39 maps command.PepperMxGateway:ApiKeyPepper.
SEC-17 Not started Confirmed open Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs:22-48 overrides only unary + server-streaming. All current RPCs remain unary/server-streaming.
SEC-18 Not started Confirmed open Dashboard/DashboardApiKeyManagementService.cs:22 (PepperUnavailableMarker = "pepper unavailable" message match).
SEC-19 Not started Confirmed open Security/Audit/SqliteCanonicalAuditStore.cs:22,29CREATE TABLE IF NOT EXISTS audit_event still issued per write/read.
SEC-21 Not started Confirmed open Dashboard/DashboardSnapshotService.cs:142,163,256RefreshApiKeySummariesAsync still runs every publish tick regardless of audience.
SEC-23 Not started Confirmed open GatewayOptionsValidator.ValidateDashboard (:307-352) gained the SEC-04 production guard but still ignores AutoLoginUser, RequireHttpsCookie, and CookieName (no __Host--without-Secure consistency check).
SEC-24 Not started Confirmed open Configuration/GatewayConfigurationProvider.cs:58-59 — effective dashboard config still projects only Enabled/AllowAnonymousLocalhost; no DisableLogin/AutoLoginUser/RequireHttpsCookie/CookieName.
SEC-26 Not started Confirmed open No retention/prune path in Security/Audit/SqliteCanonicalAuditStore.cs (grep: no DELETE/retention).
SEC-27 Not started Confirmed open Dashboard/DashboardSnapshotService.cs:17,96GatewayStatus still the hardcoded "Healthy" constant.

New findings

SEC-31 · Medium (Security) — API-key lockout DoS: the gRPC failure limiter partitions on an attacker-controlled key id and blocks before verification

Files: src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs:72-77,115-136; src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ApiKeyFailureLimiter.cs:65-114; src/ZB.MOM.WW.MxGateway.Server/Configuration/SecurityOptions.cs:51-57.

ResolvePeerKey extracts the key id from the unauthenticated presented token (parts[1] of mxgw_<id>_<secret>, interceptor :127-132) and IsBlocked short-circuits with ResourceExhausted before VerifyAsync runs (:72-77). The success-reset (:97) is only reachable after a verification — which a blocked peer never gets. Consequence: any network peer that knows a key id can send 10 garbage-secret requests per 60-second window (defaults, SecurityOptions.cs:51,57) and deny that key indefinitely; the legitimate client presenting the correct secret is rejected before its credential is ever checked. Key ids are not secrets: they are embedded in every token, displayed to every dashboard Viewer, and pushed to anonymous-loopback hub clients in the snapshot's API-key inventory. This inverts the NAT-caveat rationale (the comment at :113-114 optimizes for not locking out co-located clients, but hands remote attackers a per-key kill switch). The dashboard login limiter does not have this defect (partitioned on remote IP, DashboardEndpointRouteBuilderExtensions.cs:31).

Recommendation: partition on the (transport peer, key id) pair — context.Peer is already available — so a remote attacker only locks out their own address's attempts against the key; or, if key-id-scoped blocking is retained for NAT reasons, allow a trickle of verifications while blocked (e.g. one probe per few seconds) so a legitimate correct secret can still authenticate and reset the counter.

SEC-32 · Low (Security) — Failure-limiter LRU eviction is flushable by spraying unique peer keys, weakening the brute-force bound

Files: src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ApiKeyFailureLimiter.cs:124-148; src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs:127-132.

The tracked-peer map evicts the least-recently-active entry once it exceeds ApiKeyFailureTrackedPeers (default 4096). Because the partition key is derived from attacker-chosen token text (any a_b_c-shaped garbage mints a fresh key:b partition; the prefix is not even checked against mxgw), a guesser can interleave real guesses with ~4096 unique throwaway tokens to evict its own (or the SEC-31 victim's) PeerState and reset the window, resuming guessing past the intended 10-per-minute bound. The flush costs ~4096 requests each cycle, so the limiter still slows the attack materially — hence Low — but the documented guarantee ("a spray of unique peer keys cannot grow memory without limit", which is true, silently trades away the blocking guarantee).

Recommendation: never evict an entry that is currently blocked (or whose window still holds failures ≥ limit); prefer evicting entries whose windows have fully expired. Combine with the SEC-31 re-keying so partitions are bound to transport peers, which caps how many partitions one peer can mint.

SEC-33 · Low (Security/Stability) — The "rooted for any platform" acceptance re-opens the SEC-01 runtime failure on non-Windows hosts; a fresh stray auth DB exists in the tree (build output)

Files: src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:541-560; src/ZB.MOM.WW.MxGateway.Server/appsettings.json:17,80; src/ZB.MOM.WW.MxGateway.Tests/ProjectStructure/GatewayTreeHygieneTests.cs:30-35.

IsRootedForAnyPlatform deliberately passes Windows drive-qualified/UNC paths on Unix so the shipped appsettings.json validates cross-platform. But the validator runs in the same process that then uses the path: on a Unix host, C:\ProgramData\MxGateway\gateway-auth.db contains no Unix separators, SQLite treats it as one relative filename, and the credential store lands in the CWD — exactly the original SEC-01 mechanism, now with a validator error message that promises it "never lands in the launch working directory" (:112). Concrete evidence at HEAD: src/ZB.MOM.WW.MxGateway.Tests/bin/Debug/net10.0/C:\ProgramData\MxGateway\gateway-auth.db dated 2026-07-09 — created after the fix landed, by a test/tool run resolving the Windows path relative to the test bin dir. It is gitignored and invisible to the hygiene test (bin/obj excluded), so the guard only protects the tracked tree, not the runtime behavior. Additionally, Galaxy SnapshotCachePath (appsettings.json:80) is bound by the shared GalaxyRepository package and gets no rooting validation at all.

Recommendation: treat foreign-platform-rooted paths as a startup error on the host that cannot use them (i.e. check Path.IsPathRooted for the running OS at runtime, keeping the any-platform acceptance only for tooling that validates configs offline), or resolve per-OS defaults in config composition; extend rooting validation to Galaxy SnapshotCachePath.

SEC-34 · Low (Security) — Verification cache: expired/out-of-band-revoked keys stay valid up to TTL, and an in-flight verification can re-populate the cache after Invalidate

Files: src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/CachingApiKeyVerifier.cs:96-120,123-137; src/ZB.MOM.WW.MxGateway.Server/Configuration/SecurityOptions.cs:21.

Three bounded staleness windows in the new cache: (1) a key revoked via the CLI (separate process — its cache is not the gateway's) keeps authenticating for up to 15 s, which the remarks document; (2) a key whose ExpiresUtc passes while its verification is cached also keeps working ≤15 s past expiry — expiry is enforced by the inner library verifier, which a cache hit never reaches, and this case is not mentioned in the documented backstop rationale; (3) race: a VerifyAsync that passed the inner verifier just before a dashboard revoke can _cache.Set after Invalidate(keyId) ran (:108-117 vs :130-136 — no epoch/version check), re-caching the revoked identity for a full TTL despite the "gateway-initiated mutations take effect immediately" contract (:11-13). All three are bounded by the 15 s TTL (worst case ~30 s for the race), so exposure is small; the cache-key design itself is sound (SHA-256 of the full token, success-only caching, constant-time compare stays in the library on every miss, no plaintext secrets in memory).

Recommendation: document the expiry-past-TTL window alongside the revocation backstop; for the race, stamp cache entries with a per-key generation counter bumped by Invalidate and discard Sets from an older generation (or simply accept and document it — at a 15 s TTL the risk is minimal).

SEC-35 · Info (Conventions) — Production hard-stops key on the exact Production environment name

Files: src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:23-27,161-165,314-318; src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs:24,38-51.

The DisableLogin and plaintext-LDAP guards fire only when IHostEnvironment.IsProduction() — i.e. ASPNETCORE_ENVIRONMENT unset (defaults to Production, so the NSSM-deployed hosts are covered) or exactly Production. A host launched as Staging, Prod, or any custom name silently disarms both guards while remaining a real deployment. The FallbackHostEnvironment (Development) is registered via TryAdd and only wins in bare test/tooling containers, which is correct. Acceptable as designed, but worth stating in docs/GatewayConfiguration.md: the guards are environment-name-based, and non-Production names carry the permissive dev posture.

Recommendation: document the environment-name contract; optionally treat "not Development" as production-like for these two guards.

SEC-36 · Low (Security) — The committed dev LDAP service-account password remains in appsettings.json and was not rotated (SEC-06 residual)

Files: src/ZB.MOM.WW.MxGateway.Server/appsettings.json:29; docs/GatewayConfiguration.md:248.

The SEC-06 remediation shipped the production transport hard-stop and the env-var override documentation, but the credential itself (Ldap.ServiceAccountPassword) is still committed at HEAD and — per the doc's own wording — still awaiting rotation. As long as the shared GLAuth instance honors this credential, the repo discloses a live directory service account. (Not reproducing the value here; see the cited line.)

Recommendation: rotate the GLAuth service-account credential (source of truth scadaproj/infra/glauth/), replace the committed value with a placeholder or drop the key entirely (the validator already fails a blank password only when LDAP is enabled — dev can supply it via user-secrets/env), and note the rotation in glauth.md.

Severity roll-up (new findings)

Severity Count IDs
Medium 1 SEC-31
Low 4 SEC-32, SEC-33, SEC-34, SEC-36
Info 1 SEC-35

Still good / preserved

The load-bearing controls from SEC-28 all survive at HEAD: fail-closed unrecognized-request → admin (GatewayGrpcScopeResolver.cs:29); opaque auth failures with the stage never disclosed (GatewayGrpcAuthorizationInterceptor.cs:82-93, and the new ResourceExhausted throttle message reveals nothing about secret validity); constant-time compare stays inside the shared verifier on every cache miss, with only SHA-256 token digests held in memory (CachingApiKeyVerifier.cs:36-39,166-168); SanitizeReturnUrl still blocks open redirects (DashboardEndpointRouteBuilderExtensions.cs:237-248); single generic login-failure message; antiforgery on POST login/logout (:145,181); the self-signed PFX generation still hardens permissions before writing key bytes and now uses unique temp names to avoid concurrent-writer collisions (Security/Tls/SelfSignedCertificateProvider.cs:163-176); anonymous-loopback hub-token minting is harmless (a principal with no name/id produces a token Validate rejects, HubTokenService.cs:90-93); no logging call site emitting passwords, secrets, peppers, or credentials was found in the changed files; and the UI-stack rule (local Bootstrap only) still holds. The dashboard Close/Kill service-layer Admin re-checks remain in place beneath the now-correct policy layer (DashboardSessionAdminService.cs:49-55), giving genuine defense-in-depth.