fix(sql): reject Unicode format chars in identifiers; stop overclaiming catalog validation

Three findings from the code review of the dialect seam:

- QuoteIdentifier used char.IsControl, which only covers Unicode category Cc.
  Bidi overrides, zero-width spaces, soft hyphen and BOM are category Cf and
  passed through untouched. They cannot escape the brackets, so this is not an
  injection bypass — but the method rejects control characters precisely to
  protect the integrity of logged/audited statements, and Cf characters spoof
  rendered text while comparing byte-different from the real catalog name
  (Trojan Source, CVE-2021-42574). Same rule, same rationale, wider category.

- The XML docs on both files asserted that identifiers 'are sourced only from
  catalog-validated (INFORMATION_SCHEMA) names' and that rejection is 'a
  backstop, not the primary defence'. Neither is true yet: design §8.1 does
  specify that gate, but nothing implements it and no task in the plan
  schedules one, so an authored TagConfig table/column reaches QuoteIdentifier
  unfiltered. The docs now say so, because under-scrutinising this method on
  the strength of a filter that does not exist is exactly the failure mode.

- The control-character test only pinned NUL, so a regression to a
  Contains('\0') check would still have passed. Pinned the whole Cc category
  plus the new Cf rule; verified load-bearing by deleting the guard and
  observing exactly the 5 new cases go red.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 14:07:54 -04:00
parent a05cff794c
commit 50f88b938f
3 changed files with 48 additions and 7 deletions
@@ -11,10 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// Those live here, so no dialect assumption leaks into the reader, the poll planner, or the schema browser.
/// <para><b>This interface is the driver's SQL-injection boundary.</b> Every runtime <em>value</em> is
/// bound as a <see cref="DbParameter"/> and never reaches a command text. Identifiers cannot be
/// parameterized in SQL, so the few that must appear as text are sourced only from catalog-validated
/// (<c>INFORMATION_SCHEMA</c>) names and emitted through <see cref="QuoteIdentifier"/>. Any code path that
/// builds SQL by concatenating an authored tag field <em>without</em> going through
/// <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
/// parameterized in SQL, so the few that must appear as text are emitted through
/// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field
/// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
/// <para><b>Not yet implemented:</b> design §8.1 also specifies that an authored table/column is
/// validated against the live catalog before it is ever quoted into text, so that an unknown identifier
/// rejects the tag. No such gate exists yet — an identifier reaches <see cref="QuoteIdentifier"/>
/// straight from the authored <c>TagConfig</c> blob, so <b>quoting is currently the only defence</b>,
/// not a backstop behind an upstream filter. Scrutinise it accordingly.</para>
/// <para>Public because <c>Driver.Sql.Browser</c> consumes it — the catalog SQL <em>is</em> the browse
/// engine, so it is shared rather than duplicated. Implementations own their provider package;
/// <b>no provider-specific type appears in this signature</b> (<see cref="Factory"/> is the abstract