review(AdminUI): fix null-TagConfig crash, CTS leak, unencoded historian tag

Review at HEAD 7286d320. AdminUI-002: IsValidJson null/blank -> friendly error (was
ArgumentNullException). AdminUI-003: DriverStatusPanel Reconnect/Restart dispose CTS (build-
verified, live /run deferred). AdminUI-005: HistorianWonderware picker URL-encodes tag name.
AdminUI-008: Format round-trip test. 001 (script-page authz) + 004 (hub [Authorize]) left
Open as cross-cutting w/ Host/Security.
This commit is contained in:
Joseph Doherty
2026-06-19 10:52:23 -04:00
parent 1aa7905676
commit 3c908f1df0
7 changed files with 306 additions and 5 deletions
+219
View File
@@ -0,0 +1,219 @@
# Code Review — AdminUI
| Field | Value |
|---|---|
| Module | `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` |
| Reviewer | Claude Code |
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 2 |
## Checklist coverage
| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | AdminUI-002, AdminUI-005, AdminUI-008 (resolved) |
| 2 | OtOpcUa conventions | No issues found |
| 3 | Concurrency & thread safety | AdminUI-003 (resolved) |
| 4 | Error handling & resilience | AdminUI-006 (won't fix) |
| 5 | Security | AdminUI-001 (open), AdminUI-004 (open) |
| 6 | Performance & resource management | No issues found |
| 7 | Design-document adherence | AdminUI-001 (open) |
| 8 | Code organization & conventions | AdminUI-007 (won't fix) |
| 9 | Testing coverage | Addressed via the AdminUI-002/005/008 regression tests |
| 10 | Documentation & comments | No issues found |
## Findings
### AdminUI-001
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Security |
| Location | `Components/Pages/ScriptEdit.razor:5`, `Components/Pages/Scripts.razor` |
| Status | Open |
**Description:** The Script CRUD surface (`/scripts/new`, `/scripts/{id}`, and the `/scripts` list) is
gated by only `[Authorize]` (any authenticated user), whereas the *same editor's* Roslyn-analysis
backend (`ScriptAnalysisEndpoints`) requires the `FleetAdmin` policy, and `/deployments` requires
`Administrator,Designer`. A virtual-tag script is executable C# that runs server-side in the sandbox at
deploy time, so saving/editing script source is a privileged operation; gating it below the analysis
backend and the deploy action is an inconsistency in the privilege model. A `ReadOnly`-tier operator
who can authenticate can currently create and edit script bodies (they cannot deploy them, and the
runtime is sandboxed, which is why this is Medium and not High).
**Recommendation:** Align the Script pages' authorization with the privilege model — gate `ScriptEdit`
and `Scripts` with `Authorize(Roles = "Administrator,Designer")` (matching `/deployments`) or the
`FleetAdmin` policy (matching the analysis backend). This is a policy/attribute change on Razor pages;
confirm the intended tier with the security owner, since it changes who can reach the editor. Left Open
pending that decision (an authorization-policy decision, not a self-contained bug fix).
**Resolution:** _(open)_
### AdminUI-002
| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `Uns/UnsTreeService.cs:1284` (`IsValidJson`) |
| Status | Resolved |
**Description:** `CreateTagAsync`/`UpdateTagAsync` validate the tag config with
`IsValidJson(input.TagConfig)`, which calls `JsonDocument.Parse(json)`. The `catch` only handles
`JsonException`. If `input.TagConfig` is `null`, `JsonDocument.Parse((string)null)` throws
`ArgumentNullException`, which escapes `IsValidJson` and faults the mutation with an unhandled exception
(unfriendly 500) instead of returning the intended "TagConfig is not valid JSON." result. The TagModal
always supplies `"{}"`, so this is latent, but the service is a public seam (also exercised by tests)
and should treat null defensively.
**Recommendation:** Treat null/blank as invalid in `IsValidJson` (return `false` on null/whitespace) so
the friendly result is returned for every caller.
**Resolution:** Resolved 2026-06-19 — `IsValidJson` returns `false` for null/whitespace before
`JsonDocument.Parse`, so a null `TagConfig` yields the friendly "not valid JSON" result instead of an
unhandled `ArgumentNullException`. TDD:
`UnsTreeServiceTagTests.CreateTag_with_null_TagConfig_returns_friendly_error` and
`...with_blank_TagConfig_returns_friendly_error` (failing → fixed → passing). (SHA pending.)
### AdminUI-003
| Field | Value |
|---|---|
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `Components/Shared/Drivers/DriverStatusPanel.razor:231`, `:256` |
| Status | Resolved |
**Description:** `ReconnectAsync` and `RestartConfirmedAsync` create a `CancellationTokenSource` inline
(`new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15)).Token`) and never dispose it.
A `CancellationTokenSource` with a timeout schedules a `Timer`; not disposing it leaks that timer until
finalization. The sibling `Alerts.razor` does this correctly with `using var cts = ...`. Each
Reconnect/Restart click leaks one CTS + timer for the circuit's lifetime.
**Recommendation:** Use `using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));` and
pass `cts.Token`, matching `Alerts.razor`.
**Resolution:** Resolved 2026-06-19 — both handlers now use `using var cts` and pass `cts.Token`, so the
CTS (and its timer) is disposed when the handler returns. Razor-behavioural change verified by build only
— live `/run` deferred (no bUnit). (SHA pending.)
### AdminUI-004
| Field | Value |
|---|---|
| Severity | Low |
| Category | Security |
| Location | `Hubs/FleetStatusHub.cs`, `Hubs/AlertHub.cs`, `Hubs/ScriptLogHub.cs` (vs `Hubs/DriverStatusHub.cs:12`) |
| Status | Open |
**Description:** Of the four SignalR hubs mapped by `MapOtOpcUaHubs`, only `DriverStatusHub` carries an
explicit `[Authorize]` attribute. `FleetStatusHub`, `AlertHub`, and `ScriptLogHub` (which broadcast
fleet status, alarm transitions with equipment paths/messages, and script-log diagnostic lines) have no
explicit authorization metadata. In the current Host wiring they ARE protected against unauthenticated
access by the global `FallbackPolicy = RequireAuthenticatedUser`
(`Security/ServiceCollectionExtensions.cs:144`), so this is not an open hole today — but the protection
is implicit and inconsistent. If the fallback policy were ever relaxed, or a hub mapped from a different
host without it, these three hubs would silently become anonymous.
**Recommendation:** Add `[Authorize]` to `FleetStatusHub`, `AlertHub`, and `ScriptLogHub` to match
`DriverStatusHub`, so each hub's authorization requirement is explicit and does not rely solely on the
host's fallback policy. Low-risk (the fallback already requires auth, so observable behaviour is
unchanged) but left Open because hub authorization is a cross-cutting concern worth confirming with the
Host/Security owners rather than silently changing in a single-module pass.
**Resolution:** _(open)_
### AdminUI-005
| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `Components/Shared/Drivers/Pickers/HistorianWonderwareAddressBuilder.cs:11` |
| Status | Resolved |
**Description:** `HistorianWonderwareAddressBuilder.Build` interpolates the tag name straight into a
query string: `$"{tagName}?mode={mode}&interval={interval}"`. A historian tag name containing `?`, `&`,
`#`, or `=` would corrupt the produced address (the part after a `&` in the name would be parsed as a
spurious query parameter by any downstream consumer that splits on `&`). Historian tag names are
typically dotted identifiers, so this is unlikely in practice, but the builder is unit-tested as a pure
helper and should produce a well-formed string for any input.
**Recommendation:** URL-encode the tag name (`Uri.EscapeDataString(tagName)`) before composing the query
string.
**Resolution:** Resolved 2026-06-19 — the tag name is `Uri.EscapeDataString`-encoded before being placed
in the query string, so a name containing reserved characters produces a well-formed address. TDD:
`HistorianWonderwareAddressBuilderTests.Build_escapes_reserved_characters_in_tag_name`
(failing → fixed → passing). (SHA pending.)
### AdminUI-006
| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | `Uns/UnsTreeService.cs:368`, `:494`, `:707`, `:993`, `:1191`, `:1518` |
| Status | Won't Fix |
**Description:** The delete paths catch a bare `Exception` and surface `ex.Message` verbatim into the
operator-facing `UnsMutationResult.Error` (e.g. `$"Delete failed: {ex.Message}. Likely because …"`). On
a real SQL Server an FK-violation message can include server/schema/constraint detail. This is low risk
(the AdminUI is an authenticated fleet-admin surface, not public, and the messages are deliberately
operator-actionable) but echoing raw provider exception text into the UI is a minor information-exposure
/ robustness smell.
**Recommendation:** Either keep as-is (intentional operator guidance) or log `ex` at Warning and surface
a fixed message without the raw `ex.Message`.
**Resolution:** Won't Fix (2026-06-19) — intentional design. The AdminUI is an authenticated
fleet-admin-only surface and the raw message is paired with deliberate operator guidance ("…remove its
children first"). Removing the provider text would reduce diagnosability for the operators who own these
operations. Recorded as a known, accepted trade-off.
### AdminUI-007
| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `Clients/AdminOperationsClient.cs:37`, `:52`, `:69` |
| Status | Won't Fix |
**Description:** `StartDeploymentAsync`, `AcknowledgeAlarmAsync`, and `ShelveAlarmAsync` pass BOTH an
explicit `AskTimeout` and a linked CTS that itself already has `CancelAfter(AskTimeout)`:
`_proxy.Ask<T>(msg, AskTimeout, linked.Token)`. The two timeouts are redundant (the Ask-level timeout
and the token both fire at 10s). Harmless, but the doubled timeout is slightly confusing — the
`AskAsync<T>` generic at `:78` correctly relies on the caller's token alone.
**Recommendation:** Drop one of the two — either pass only `linked.Token` (relying on the CTS timeout) or
only `AskTimeout` (dropping the linked CTS). Cosmetic.
**Resolution:** Won't Fix (2026-06-19) — the belt-and-suspenders pairing is harmless and the two
mechanisms guard slightly different failure modes (the Ask timeout is Akka's own deadline; the linked CTS
also honours caller cancellation). Not worth a behavioural change to a working control-plane client in a
review pass.
### AdminUI-008
| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | `ScriptAnalysis/ScriptAnalysisService.cs:376` (`Format`) |
| Status | Resolved |
**Description:** `Format` documents a "return the original source unchanged on un-formattable input"
contract (the `catch` returns `req.Code`), but no regression test locked that contract for null/blank or
unparseable input, so a future change to the catch could silently break the editor's format action. (No
behavioural bug found; this is a test-coverage gap on a documented contract.)
**Recommendation:** Add a regression test asserting `Format` returns the input unchanged for
null/blank/garbage input and round-trips valid input.
**Resolution:** Resolved 2026-06-19 — added `FormatTests.Format_returns_input_unchanged_for_null_or_empty` and
`Format_returns_input_for_unparseable_garbage`, locking the return-original contract (the existing
`FormatTests` already covered the happy path). Coverage-only; no production change. (SHA pending.)