4dc2ad8084
Until now ISqlDialect.QuoteIdentifier was the SOLE defence for authored
table/column names: an identifier went from the TagConfig blob straight into a
command text, bracket-quoted. The residual risk was bounded — a hostile name is
quoted into one nonexistent object, the query fails after the connection opens,
and the tag Bad-codes — but "bounded" is not "filtered", and the design promised
a filter. Both ISqlDialect and SqlServerDialect carried a doc paragraph saying so.
SqlCatalogGate + SqlCatalogLoader now resolve every authored identifier against
the live catalog at Initialize, and REPLACE it with the catalog's own spelling.
The identifier text in an emitted poll query is therefore a string this driver
read back out of ListSchemas/ListTables/ListColumns — not operator input. Quoting
becomes the backstop it was documented to be.
Decisions worth knowing before touching this:
- Substitution, not just validation. Matching is exact-ordinal first, then a
UNIQUE case-insensitive hit: SQL Server's default collation is CI so case
variants have always worked, and rejecting them would break valid deployments.
An ambiguous CI match under a case-sensitive collation is refused rather than
guessed — picking one would publish another column's data under the operator's
node, which is worse than rejecting the tag.
- Charset check BEFORE catalog lookup. Each identifier goes through
QuoteIdentifier for its rejection rules first, so a name carrying a control or
Unicode format character is rejected WITHOUT its value being echoed into a log
line (Trojan-Source). A name that passes is safe to render, which is why
catalog-miss messages do name it — an operator hunting a typo has to see what
they wrote. Both halves are pinned by tests.
- A rejected tag keeps its node. It is dropped from the POLLED table but stays in
the AUTHORED table, so it still materializes and reads BadNodeIdUnknown —
§8.1's specified outcome. My first wiring dropped it from both, which deleted
the node instead; an existing test (ReinitializeAsync_recoversFromFaulted)
caught it. A status code can only be published by a node that exists, and a
missing address-space entry is far harder to diagnose than a Bad quality.
- An unreadable catalog FAULTS Initialize; it does not reject every tag. That is
the absence of evidence about the tags, not evidence against them — rejecting
all of them would serve a confidently-empty address space and send the operator
hunting typos that do not exist. Zero visible schemas is treated the same way,
because that is exactly what a missing GRANT looks like. Faulting lands
DriverInstanceActor in Reconnecting with its retry timer running.
- Bounded load: one schema list, one default-schema scalar, then one ListTables
per distinct authored schema and one ListColumns per distinct authored relation
— never a full catalog enumeration, and nothing after Initialize. Every
authored name reaches the catalog queries as a bound @schema/@table parameter,
so building the allow-list cannot itself be an injection vector.
- ISqlDialect gains DefaultSchemaSql ("SELECT SCHEMA_NAME()"). An unqualified
`TagValues` must resolve in whatever schema the SERVER reports; hardcoding dbo
would be a silent lie on any estate that maps service accounts to their own
default schema. It is a query, not a constant, because the answer is
per-connection.
- Accepted v1 limitation: a 3-part db.schema.table (or linked-server name)
addresses a catalog this connection cannot enumerate, so it cannot be
allow-listed and is rejected with a message pointing at the fix — expose the
data through a view in the connected database.
VerifyLivenessAsync's wall-clock pattern is extracted to RunBoundedAsync and
reused, so the new I/O gets the same R2-01 / STAB-14 protection rather than a
second hand-rolled copy. (It also fixes a latent bug in the extracted code: the
OperationCanceledException arm detached the wrong task.)
Tests: 24 pure gate tests + 9 end-to-end driver tests against the real SQLite
catalog + 3 new live tests against the real SQL Server on 10.100.0.35 (21 in that
suite now pass, exercising the real SELECT SCHEMA_NAME() + INFORMATION_SCHEMA
path). Verified falsifiable: bypassing the gate turns exactly the three rejection
assertions red and leaves the rest green.
SqlInjectionRegressionTests is deliberately NOT rewritten to expect
BadNodeIdUnknown. It drives SqlPollReader directly, below the gate, and pins the
quoting backstop on its own — defence in depth is only worth the name if each
layer holds independently. Rewriting those assertions would delete the backstop's
only coverage and leave the gate a single point of failure. Its scope note, which
said the gate does not exist, is updated to say why it stays where it is.
138 lines
7.1 KiB
C#
138 lines
7.1 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
|
|
|
/// <summary>
|
|
/// An immutable snapshot of the parts of a database's catalog the authored tags actually name — the
|
|
/// allow-list design §8.1 requires identifiers to come from.
|
|
/// <para><b>This type holds catalog strings only.</b> Every name in it was read back out of
|
|
/// <see cref="ISqlDialect.ListSchemasSql"/> / <see cref="ISqlDialect.ListTablesSql"/> /
|
|
/// <see cref="ISqlDialect.ListColumnsSql"/>; nothing an operator typed is ever stored here. That is what
|
|
/// lets <see cref="SqlCatalogGate"/> hand the planner catalog spellings rather than authored ones, so the
|
|
/// identifier text in an emitted query is a string the database gave us.</para>
|
|
/// <para><b>Deliberately partial.</b> Only the schemas/tables the authored tags name are loaded — a
|
|
/// driver polling three tables must not enumerate a warehouse's ten thousand. A name absent from this
|
|
/// snapshot therefore means "not found <em>when we looked</em>", which is exactly the question the gate
|
|
/// asks.</para>
|
|
/// </summary>
|
|
public sealed class SqlCatalog
|
|
{
|
|
private readonly IReadOnlyList<string> _schemas;
|
|
|
|
/// <summary>Canonical schema → canonical table names in it.</summary>
|
|
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _tablesBySchema;
|
|
|
|
/// <summary>Canonical <c>schema.table</c> → that relation's canonical column names.</summary>
|
|
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _columnsByTable;
|
|
|
|
/// <summary>Constructs a catalog snapshot.</summary>
|
|
/// <param name="defaultSchema">The schema an unqualified object name resolves to for this connection.</param>
|
|
/// <param name="schemas">Every schema the connection can see.</param>
|
|
/// <param name="tablesBySchema">Canonical schema → its tables/views.</param>
|
|
/// <param name="columnsByTable">Canonical <c>schema.table</c> → its columns.</param>
|
|
internal SqlCatalog(
|
|
string defaultSchema,
|
|
IReadOnlyList<string> schemas,
|
|
IReadOnlyDictionary<string, IReadOnlyList<string>> tablesBySchema,
|
|
IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable)
|
|
{
|
|
DefaultSchema = defaultSchema;
|
|
_schemas = schemas;
|
|
_tablesBySchema = tablesBySchema;
|
|
_columnsByTable = columnsByTable;
|
|
}
|
|
|
|
/// <summary>The schema an unqualified authored object name is resolved in.</summary>
|
|
public string DefaultSchema { get; }
|
|
|
|
/// <summary>Resolves an authored schema name to the catalog's own spelling.</summary>
|
|
/// <param name="authored">The authored schema, or null/blank for the default schema.</param>
|
|
/// <param name="canonical">The catalog's spelling, when resolved.</param>
|
|
/// <returns><see langword="true"/> when the schema exists (or the default schema is used).</returns>
|
|
public bool TryResolveSchema(string? authored, out string canonical)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(authored))
|
|
{
|
|
canonical = DefaultSchema;
|
|
return true;
|
|
}
|
|
|
|
return TryMatch(_schemas, authored, out canonical);
|
|
}
|
|
|
|
/// <summary>Resolves an authored table/view name within a canonical schema.</summary>
|
|
/// <param name="canonicalSchema">The schema, already resolved through <see cref="TryResolveSchema"/>.</param>
|
|
/// <param name="authored">The authored table or view name.</param>
|
|
/// <param name="canonical">The catalog's spelling, when resolved.</param>
|
|
/// <returns><see langword="true"/> when the relation exists in that schema.</returns>
|
|
public bool TryResolveTable(string canonicalSchema, string authored, out string canonical)
|
|
{
|
|
canonical = string.Empty;
|
|
return _tablesBySchema.TryGetValue(canonicalSchema, out var tables)
|
|
&& TryMatch(tables, authored, out canonical);
|
|
}
|
|
|
|
/// <summary>Resolves an authored column name within a canonical relation.</summary>
|
|
/// <param name="canonicalSchema">The resolved schema.</param>
|
|
/// <param name="canonicalTable">The resolved table or view.</param>
|
|
/// <param name="authored">The authored column name.</param>
|
|
/// <param name="canonical">The catalog's spelling, when resolved.</param>
|
|
/// <returns><see langword="true"/> when the column exists on that relation.</returns>
|
|
public bool TryResolveColumn(
|
|
string canonicalSchema, string canonicalTable, string authored, out string canonical)
|
|
{
|
|
canonical = string.Empty;
|
|
return _columnsByTable.TryGetValue(QualifiedKey(canonicalSchema, canonicalTable), out var columns)
|
|
&& TryMatch(columns, authored, out canonical);
|
|
}
|
|
|
|
/// <summary>The dictionary key for a canonical relation.</summary>
|
|
/// <param name="schema">The canonical schema.</param>
|
|
/// <param name="table">The canonical table.</param>
|
|
/// <returns>The composite key.</returns>
|
|
internal static string QualifiedKey(string schema, string table) => schema + "." + table;
|
|
|
|
/// <summary>
|
|
/// Matches an authored name against catalog spellings: an exact (ordinal) hit wins, otherwise a
|
|
/// <b>unique</b> case-insensitive hit is accepted and its catalog spelling returned.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>Why case-insensitive at all.</b> SQL Server's default collation is case-insensitive, so
|
|
/// <c>num_value</c> and <c>NUM_VALUE</c> genuinely name the same column and both work today. Matching
|
|
/// ordinally would reject configurations that have always been valid — a gate that breaks working
|
|
/// deployments is not defence in depth.</para>
|
|
/// <para><b>Why exact-first, and why ambiguity fails.</b> On a case-<em>sensitive</em> collation a
|
|
/// relation may legitimately carry both <c>Value</c> and <c>value</c>. Preferring the exact match keeps
|
|
/// such a config resolving to what the operator wrote, and refusing to choose between two
|
|
/// case-insensitive candidates is the only safe answer — silently picking one would publish a different
|
|
/// column's data under the operator's node, which is worse than rejecting the tag.</para>
|
|
/// </remarks>
|
|
/// <param name="candidates">The catalog spellings to match against.</param>
|
|
/// <param name="authored">The authored name.</param>
|
|
/// <param name="canonical">The catalog's spelling, when matched.</param>
|
|
/// <returns><see langword="true"/> on an unambiguous match.</returns>
|
|
internal static bool TryMatch(IReadOnlyList<string> candidates, string authored, out string canonical)
|
|
{
|
|
foreach (var candidate in candidates)
|
|
{
|
|
if (!string.Equals(candidate, authored, StringComparison.Ordinal)) continue;
|
|
canonical = candidate;
|
|
return true;
|
|
}
|
|
|
|
canonical = string.Empty;
|
|
var matches = 0;
|
|
foreach (var candidate in candidates)
|
|
{
|
|
if (!string.Equals(candidate, authored, StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (++matches > 1)
|
|
{
|
|
canonical = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
canonical = candidate;
|
|
}
|
|
|
|
return matches == 1;
|
|
}
|
|
}
|