docs(reviews): close Driver.Sql-009 + Driver.Sql.Browser-001/002/003

-003 is a documented Won't Fix: the mitigating control is the ConfigEditor page
policy on the only two surfaces that reach a browse session (/raw and /uns),
there is no HTTP endpoint for browse, and PageAuthorizationGuardTests pins both
pages to that exact policy. Recorded in docs/security.md under Control-Plane
Authorization and pointed at from SqlDriverBrowser's class summary.
This commit is contained in:
Joseph Doherty
2026-08-10 08:05:12 -04:00
parent cd4b29a368
commit 3427370831
4 changed files with 63 additions and 9 deletions
+22 -7
View File
@@ -11,7 +11,7 @@
| Review date | 2026-08-10 |
| Commit reviewed | `b990adae` |
| Status | Reviewed |
| Open findings | 3 |
| Open findings | 0 |
## Checklist coverage
@@ -37,13 +37,17 @@
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:290` |
| Status | Open |
| Status | Resolved |
**Description:** `PreferredTimestampColumn` nominates the first column whose dialect-mapped type is `DriverDataType.DateTime`. That set includes **date-only** types: `SqlServerDialect.MapColumnType` maps `"date"` to DateTime, and so does `PostgresDialect` for `date`. A table whose first date/time column is a `date` therefore commits `rowSelector.topByTimestamp = <date column>`, and the resulting wide-row query is `ORDER BY <date> DESC` limited to one row — which ties for every row written on the same day, so "the newest row" becomes storage order among the ties. The reader cannot warn about it either, because the server has already discarded the other rows (see Driver.Sql-008). The choice is a visible, editable default, so the impact is bounded to a tag that silently binds to an arbitrary row of the latest day until someone notices.
**Recommendation:** Prefer a time-bearing type when the table has one — i.e. rank candidates and fall back to a date-only column only when nothing better exists — or drop date-only types from candidacy entirely, which makes such a table "not bindable by browse" and routes it through the existing `DescribeUncommittableLeaf` message. Either way state the choice in `docs/drivers/Sql.md`, which currently describes candidacy as simply "date/time-typed".
**Resolution:** _(open)_
**Resolution (2026-08-10):** Fixed in `a8cfc886`, taking the **ranking** option rather than dropping date-only types. `PreferredTimestampColumn` now returns the first time-bearing candidate outright and remembers the first date-only one as a fallback; the tie-break within a rank is still first-in-catalog-order. Dropping date-only candidacy was rejected because it turns a table whose only date/time column is a `date` from "bound to an editable, imperfect default" into "un-committable" — a larger behaviour change than the defect.
The date-only test is **provider-qualified** (`IsDateOnly`, keyed off `ISqlDialect.Provider`): `date` is date-only on SQL Server and PostgreSQL, but **Oracle's `DATE` carries a time-of-day component** — which is exactly why `OracleDialect.MapColumnType` calls DateTime the faithful mapping there — so demoting it on Oracle would change the pick on the one backend where `DATE` *is* the ordinary timestamp type. The knowledge lives in the browser rather than on `ISqlDialect` because it is a browse-time *preference* (what is good to sort by), not a statement about what a value is, and nothing on the poll path consumes it.
Three tests, of which one was RED. `AttributesAsync_prefersATimeBearingColumn_overAnEarlierDateOnlyOne` runs against a new fixture table (`DailyTotals`) whose date-only column is declared **first**, so it fails against the plain first-in-catalog-order rule rather than passing by luck — RED before the fix (got `shift_day`, expected `logged_at`). The other two were green before *and* after, deliberately: `…stillStatesADateOnlyColumn_whenTheTableHasNothingBetter` pins the fallback (it goes red if candidacy is dropped instead of ranked), and `…doesNotDemoteOraclesDATE_whichCarriesATimeOfDay` is the falsification control for the provider branch — it drives the same catalog through `OracleFlavouredSqliteDialect`, which differs from `SqliteDialect` only in the `Provider` it reports, and is the single test a provider-blind implementation would fail. `docs/drivers/Sql.md` §"The committed row selector" now states the ranking and the Oracle exemption (its "two properties" list became three).
---
@@ -54,13 +58,17 @@
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:204`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs:325` |
| Status | Open |
| Status | Resolved |
**Description:** Two small races around the registry reaper. (1) `DisposeAsync` disposes `_gate` unconditionally; a call already blocked in `_gate.WaitAsync` then throws `ObjectDisposedException` from inside the semaphore rather than the guarded `ObjectDisposedException.ThrowIf(_disposed, this)` the class raises everywhere else — the release side is defended against exactly this (`catch (ObjectDisposedException)` in the `finally`) but the wait side is not. (2) `LastUsedUtc` advances only after a call completes, so a slow catalog query (the AdminUI allows 20 s) is invisible to the TTL reaper for its whole duration; a session that idles up to the edge of the TTL and is then given one long expand can be disposed mid-call, tearing down the connection under an open reader.
**Recommendation:** Stamp `LastUsedUtc` on entry as well as on completion, and either leave `_gate` undisposed (the pattern `SqlPollReader` uses and documents) or drain it before disposing. Both are one-line changes; neither is observable to the picker today, which is why this is Low.
**Resolution:** _(open)_
**Resolution (2026-08-10):** Both fixed in `cd4b29a3`, taking the "leave `_gate` undisposed" option. A `SemaphoreSlim` never handed out as a `WaitHandle` holds no unmanaged resource, so not disposing it leaks nothing, and `DisposeAsync` now says why in as many words.
**Race (1) is worse than "the wrong exception type", which the RED run made plain.** `SemaphoreSlim.Dispose` does **not** complete its pending async waiters, and the in-flight call's `Release()` in the `finally` then threw `ObjectDisposedException` from inside the semaphore — which the `catch (ObjectDisposedException)` there swallowed. So nothing ever released the parked waiter: the queued call did not fail with the wrong exception, it **never returned at all**. `DisposeAsync_doesNotStrandACallerQueuedOnTheGate` reproduces the ordinary reaper shape (one call in flight behind a `ConcurrencyProbe` delay, a second parked in `WaitAsync`, then dispose) and asserts through `Task.WaitAsync(5 s)`, so a stranded caller surfaces as "expected `ObjectDisposedException`, got `TimeoutException`" — RED before the fix, taking the full 5 s. It also asserts `ObjectName == typeof(SqlBrowseSession).FullName`, which is what distinguishes the session's own guarded throw from the semaphore's and would go red again if the gate were re-disposed. With the gate left alive the `finally` release is now unconditional and cannot throw, so the swallowing `catch` is gone too.
**Race (2)**: `LastUsedUtc` is stamped at `QueryAsync` entry as well as on completion, so a queued call refreshes the session too. `LastUsedUtc_advancesWhenACallSTARTS_notOnlyWhenItCompletes` observes the stamp **while the call is still in flight** (asserting `call.IsCompleted` is false at that moment, so the completion stamp cannot account for it) — RED before the fix. It sleeps 50 ms before starting the call so the assertion is about the stamp and not about `DateTime.UtcNow`'s ~15.6 ms resolution on Windows.
---
@@ -71,10 +79,17 @@
| Severity | Low |
| Category | Security |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs:262` |
| Status | Open |
| Status | Won't Fix |
**Description:** `OpenAsync` accepts an ad-hoc `connectionString` literal from the browse form and dials it verbatim. That is an unrestricted outbound-connect primitive on the AdminUI host: an arbitrary host and port, plus whatever the provider's keyword set allows (`Microsoft.Data.SqlClient` alone offers `AttachDbFilename`, `Failover Partner` and `Enlist`). The mitigations in place are real and deliberate — the literal is never persisted (`SqlDriverConfigDto` has no such property, and the picker composes a session-only document), a paste alongside a `connectionStringRef` is logged, `NodeEdit.razor` warns against pasting one into a saved DriverConfig, and every open-path failure is redacted — but the capability itself is bounded only by whichever AdminUI policy guards the browse endpoint.
**Recommendation:** No code change proposed — the surface is reached only from `/raw`, which carries `[Authorize(Policy = AdminUiPolicies.ConfigEditor)]` (Administrator or Designer), so it is already at the right authorization level. Record the trust boundary where a reviewer will see it (`docs/security.md`, alongside the other AdminUI-authenticated capabilities): a Designer can make the AdminUI host open an arbitrary outbound database connection, which is a stronger capability than "browse a configured database" reads as.
**Resolution:** _(open)_
**Resolution — Won't Fix (2026-08-10):** Documented, not restricted, in `cd4b29a3`'s follow-up commit. **The mitigating control is the AdminUI page policy, and it is admin-grade.** Verified rather than assumed, because the finding's own wording ("bounded only by whichever AdminUI policy guards the browse endpoint") left it open:
- The reachable surfaces are exactly two — `/raw` (`GlobalRaw.razor`) and `/uns` (`GlobalUns.razor`, via `TagConfigEditorMap` → `SqlTagConfigEditor` → `SqlAddressPickerBody`) — and **both** carry `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]`, satisfied by `Administrator` or `Designer` only. `SqlAddressPickerBody` has no other consumer.
- There is **no HTTP endpoint** for browse. `IBrowserSessionService` is a scoped service resolved from those pages' Blazor circuits (`EndpointRouteBuilderExtensions` registers it and nothing maps a route), so it inherits the page policy plus the `FallbackPolicy` beneath it. Nothing here is reachable by a lesser role or anonymously.
- The gating is anti-drift: `PageAuthorizationGuardTests` pins **both** page types to `AdminUiPolicies.ConfigEditor` by exact policy name, so weakening either attribute fails the build.
- `AdminUiPolicies.DriverOperator` gates only the live-ops action *inside* a session (Sparkplug rebirth), never the connect — so the connect is not reachable at a weaker level by that route either.
Adding a restriction (an allow-list of hosts, or refusing the literal outright) was rejected as a net loss: the literal exists so an operator can browse a database that has no `connectionStringRef` yet, which is the first step of authoring one, and any allow-list would itself be config authored by the same `ConfigEditor` role — a control that the party it constrains can edit is not a control. The residual capability is stated at both altitudes instead: `docs/security.md` §"Address-picker browse is an outbound-connect capability (trust boundary)" (under Control-Plane Authorization, naming the policy, the two pages, the guard test, and the fact that the same shape applies to the OPC UA client and MQTT pickers), and a paragraph on `SqlDriverBrowser`'s own class summary pointing at it — so a reader who arrives at the code first does not have to infer the boundary from the hygiene. Existing hygiene is unchanged and explicitly labelled *supporting, not the control*: the literal is never persisted, a literal alongside a ref logs a warning naming the ref, and every open-path failure is redacted.
+2 -2
View File
@@ -184,13 +184,13 @@ surface.
| Severity | Low |
| Category | Security |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs:83` |
| Status | Open |
| Status | Resolved |
**Description:** The probe surfaces the raw parser text on a malformed config ("Config JSON is invalid: " + `ex.Message`) where `SqlDriverBrowser.ParseDocument` deliberately withholds the identical message "because the malformed text may carry a connection string". The two read the same blob shape: the AdminUI Sql picker composes an ad-hoc document carrying a top-level `connectionString` literal, so a malformed paste is exactly the case that reaches both. `System.Text.Json`'s message quotes the offending token and the JSON path rather than the property value, so the current exposure is small — but the rule is stated absolutely elsewhere in this driver ("never `ex.Message` on an operator surface") and the probe is the one place that breaks it.
**Recommendation:** Match the browser: report the failure class, not the parser's message.
**Resolution:** _(open)_
**Resolution (2026-08-10):** Fixed in `073930e0`. The `JsonException`/`ArgumentNullException` catch now returns the browser's own wording — "Config JSON is not valid JSON. (The parser's message is withheld because the malformed text may carry a connection string.)" — and the class summary states the rule beside the existing "never the connection string" paragraph, so the two disciplines read as one. Pinned by `SqlDriverProbeTests.Probe_malformedConfig_withholdsTheParserMessage`, whose malformed input is a `connectionString` literal mis-quoted the way an operator's paste is (`Password=Sup3r;SecretTail`, unquoted `;`) so the parse fails partway *through* the credential. RED before the fix on `"LineNumber"` — the old message was `Config JSON is invalid: 'P' is an invalid start of a value. Path: $.connectionString | LineNumber: 0…`. The test asserts on the parser's structural tokens (`LineNumber`, `BytePositionInLine`) rather than only on the secret, because `System.Text.Json` quotes just the offending token: a leak-only assertion passes vacuously today and would go on passing if the message ever got *more* verbose.
---
+32
View File
@@ -719,6 +719,38 @@ Every routable AdminUI page carries an explicit **named-policy** `@attribute [Au
In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUaAuth` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`), which also installs a `FallbackPolicy` that requires an authenticated user. Razor pages gate inline with the named-policy attribute above, e.g. `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]`. Nav-menu sections hide via `<AuthorizeView>`.
#### Address-picker browse is an outbound-connect capability (trust boundary)
Worth stating plainly, because "browse a configured database" reads weaker than what it is: the AdminUI's
live address pickers **dial a target named in the browse form, from the AdminUI host, before anything is
persisted.** The `Sql` picker is the sharpest case — `SqlDriverBrowser.OpenAsync`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs`) accepts an **ad-hoc
`connectionString` literal** an operator pastes for a database that has no `connectionStringRef` yet, and
hands it to the ADO.NET provider verbatim. That is an arbitrary host and port plus whatever the provider's
keyword set allows (`Microsoft.Data.SqlClient` alone offers `AttachDbFilename`, `Failover Partner`,
`Enlist`). The OPC UA client and MQTT pickers have the same shape against their own endpoint fields.
**The control is the page policy, and it is admin-grade.** The only surfaces that reach a browse session
are `/raw` and `/uns`, both `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]` — satisfied by
`Administrator` or `Designer` only. There is **no HTTP endpoint** for browse: `IBrowserSessionService` is a
scoped service consumed from those pages' Blazor circuits, so it inherits the page's policy and the
`FallbackPolicy` beneath it, and `PageAuthorizationGuardTests` fails the build if either page's attribute
drifts. A `Viewer` cannot open a picker; `AdminUiPolicies.DriverOperator` gates only the *live-ops* actions
inside one (Sparkplug rebirth), never the connect.
Supporting hygiene, none of which is the control:
- The pasted literal is **never persisted** — `SqlDriverConfigDto` has no such property, so the driver
factory would drop it; the picker composes a session-only document and the browser keeps no field.
- A literal supplied alongside a `connectionStringRef` **logs a warning** naming the shadowed ref (never
any connection text), so the override is visible rather than silent.
- Every failure on the open path is **redacted** (`SqlDriverBrowser.Sanitize`), and the provider's own
connection-string-parser message is withheld outright because it quotes connection-string text verbatim.
- `NodeEdit.razor` warns against pasting a literal into a *saved* `DriverConfig`, which would persist it.
If a deployment needs the browse surface narrower than "anyone who may author config", the lever is the
page policy (and the LDAP group→role mapping behind it), not the browser.
### Role grant source
Admin reads `LdapGroupRoleMapping` rows from the Config DB (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs`) — the same pattern as the data-plane `NodeAcl` but scoped to Admin roles + (optionally) one cluster for multi-site fleets (a system-wide row, `IsSystemWide = true`, stacks additively with cluster-scoped rows). The `RoleGrants.razor` page lets `Administrator`s edit these mappings without leaving the UI.
@@ -26,6 +26,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// the driver factory would drop it — which makes its presence proof that an operator typed it just now.
/// A config carrying both logs a warning (naming the shadowed <em>ref</em>, never any connection text) so
/// the override is visible rather than silent.</para>
/// <para><b>Trust boundary: this is an outbound-connect capability, gated by the AdminUI page policy.</b>
/// Accepting a literal means dialling an arbitrary host and port — plus whatever the provider's keyword
/// set allows — from the AdminUI host, before anything is persisted. The control is that the only
/// surfaces reaching a browse session are <c>/raw</c> and <c>/uns</c>, both
/// <c>[Authorize(Policy = AdminUiPolicies.ConfigEditor)]</c> (Administrator or Designer), with no HTTP
/// endpoint for browse at all; the hygiene below is supporting, not the control. Recorded in
/// <c>docs/security.md</c> §"Address-picker browse is an outbound-connect capability".</para>
/// <para><b>Credential hygiene is the point of this type.</b> A connection string is a secret. It is
/// passed to the provider and to nothing else: it never reaches a log line, an exception message, a field,
/// or anything persisted. Because ADO.NET providers are free to echo connection-string content in their