# 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.ApiKeys` 0.1.5 (pins bumped 0.1.4→0.1.5 in `e107019`, a transitive-dependency security fix only — the expiry enforcement is unchanged; documented `docs/Authentication.md:64-65`; not readable in this repo). | UTC semantics are correct end-to-end on the gateway side. Boundary note: `expiresAt <= now` shows Expired, and relative parse rejects signed values (`NumberStyles.None`). A just-expired key can still authenticate for ≤15 s via the verification cache — see SEC-34. |
| 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 `AuditEvent`s `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.Pepper` → `MxGateway: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,29` — `CREATE TABLE IF NOT EXISTS audit_event` still issued per write/read. | |
| SEC-21 | Not started | **Confirmed open** | `Dashboard/DashboardSnapshotService.cs:142,163,256` — `RefreshApiKeySummariesAsync` 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,96` — `GatewayStatus` 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 `Set`s 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.
