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
@@ -25,6 +25,29 @@ public class SqlServerDialectTests
public void QuoteIdentifier_rejectsControlCharsAndNul()
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier("x\0y"));
// NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category.
[Theory]
[InlineData("x\u0001y")] // C0 SOH
[InlineData("x\u001By")] // C0 ESC
[InlineData("x\u007Fy")] // DEL
[InlineData("x\u0085y")] // C1 NEL
[InlineData("x\ty")]
[InlineData("x\ny")]
public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(string ident)
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier(ident));
// Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all
// of these. They cannot escape the brackets, but they spoof rendered log/AdminUI text while comparing
// byte-different from the real catalog name (Trojan Source, CVE-2021-42574).
[Theory]
[InlineData("Sp\u200Beed")] // zero-width space
[InlineData("\u202Ediop.selbat")] // right-to-left override
[InlineData("x\u2066y\u2069")] // bidi isolates
[InlineData("x\uFEFFy")] // BOM / zero-width no-break space
[InlineData("x\u00ADy")] // soft hyphen
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(string ident)
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier(ident));
[Theory]
[InlineData("bit", DriverDataType.Boolean)]
[InlineData("int", DriverDataType.Int32)]