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:
@@ -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.)
|
||||
Reference in New Issue
Block a user