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.
242 lines
12 KiB
C#
242 lines
12 KiB
C#
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
|
|
|
/// <summary>
|
|
/// One tag the gate refused, and why. Carried rather than logged in place so the caller decides the log
|
|
/// shape and the caller's tests can assert on the reason.
|
|
/// </summary>
|
|
/// <param name="RawPath">The rejected tag's identity — its RawPath, which is trusted structure, not blob content.</param>
|
|
/// <param name="Field">The <see cref="SqlTagDefinition"/> field that failed (e.g. <c>ValueColumn</c>).</param>
|
|
/// <param name="Reason">An operator-actionable explanation, safe to log.</param>
|
|
public sealed record SqlCatalogRejection(string RawPath, string Field, string Reason);
|
|
|
|
/// <summary>The outcome of applying a <see cref="SqlCatalog"/> to a set of authored tags.</summary>
|
|
/// <param name="Accepted">
|
|
/// The surviving definitions, <b>rewritten to catalog spellings</b>. Safe to hand to
|
|
/// <see cref="SqlGroupPlanner"/>.
|
|
/// </param>
|
|
/// <param name="Rejected">Every tag the gate refused, in input order.</param>
|
|
public sealed record SqlCatalogGateResult(
|
|
IReadOnlyList<SqlTagDefinition> Accepted,
|
|
IReadOnlyList<SqlCatalogRejection> Rejected);
|
|
|
|
/// <summary>
|
|
/// Design §8.1's identifier gate: validates every authored table/column against the live catalog
|
|
/// <b>before</b> it can be quoted into SQL text, and replaces it with the catalog's own spelling.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>What this closes.</b> Until this existed, <see cref="ISqlDialect.QuoteIdentifier"/> was the
|
|
/// sole defence: an identifier went from the authored <c>TagConfig</c> 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. With the gate in place the identifier text in an emitted query is a
|
|
/// string this driver read back out of the catalog, so quoting is a backstop behind an allow-list rather
|
|
/// than the whole story.</para>
|
|
/// <para><b>Charset first, catalog second.</b> Each identifier is passed through
|
|
/// <see cref="ISqlDialect.QuoteIdentifier"/> before it is looked up, purely for its rejection rules
|
|
/// (control characters, Unicode format characters, over-length). That ordering is deliberate: a name that
|
|
/// fails the charset check is rejected <em>without its value being echoed</em>, so nothing carrying a bidi
|
|
/// override or a NUL can reach a log line through this type's rejection messages. A name that passes has
|
|
/// been proven safe to render, which is why the catalog-miss messages can afford to name it — and they
|
|
/// must, or an operator has no way to find the typo.</para>
|
|
/// <para><b>Rejection is per-tag, never driver-wide.</b> A tag naming a dropped column is dropped from the
|
|
/// driver's table, so its RawPath resolves to nothing and it publishes
|
|
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — design §8.1's specified outcome — while every other tag
|
|
/// on that database keeps polling. This mirrors the "one malformed blob must not take the whole driver
|
|
/// down" rule the tag-table build already follows.</para>
|
|
/// </remarks>
|
|
public static class SqlCatalogGate
|
|
{
|
|
/// <summary>
|
|
/// The most name parts this gate can validate. A three-part <c>db.schema.table</c> (or a
|
|
/// four-part linked-server name) addresses a catalog this connection's <c>INFORMATION_SCHEMA</c>
|
|
/// cannot see, so it cannot be allow-listed.
|
|
/// </summary>
|
|
private const int MaxNameParts = 2;
|
|
|
|
/// <summary>
|
|
/// Applies <paramref name="catalog"/> to <paramref name="definitions"/>, returning the accepted tags
|
|
/// with catalog-spelled identifiers and the rejected ones with reasons.
|
|
/// </summary>
|
|
/// <param name="definitions">The authored definitions, as parsed from their <c>TagConfig</c> blobs.</param>
|
|
/// <param name="catalog">The catalog snapshot to validate against.</param>
|
|
/// <param name="dialect">Supplies the identifier charset rules via <see cref="ISqlDialect.QuoteIdentifier"/>.</param>
|
|
/// <returns>The accepted and rejected sets.</returns>
|
|
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
|
|
public static SqlCatalogGateResult Apply(
|
|
IEnumerable<SqlTagDefinition> definitions, SqlCatalog catalog, ISqlDialect dialect)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(definitions);
|
|
ArgumentNullException.ThrowIfNull(catalog);
|
|
ArgumentNullException.ThrowIfNull(dialect);
|
|
|
|
var accepted = new List<SqlTagDefinition>();
|
|
var rejected = new List<SqlCatalogRejection>();
|
|
|
|
foreach (var definition in definitions)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(definition);
|
|
if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
|
|
accepted.Add(canonical!);
|
|
else
|
|
rejected.Add(rejection!);
|
|
}
|
|
|
|
return new SqlCatalogGateResult(accepted, rejected);
|
|
}
|
|
|
|
/// <summary>Resolves one definition's identifiers, or explains the first failure.</summary>
|
|
private static bool TryCanonicalize(
|
|
SqlTagDefinition definition,
|
|
SqlCatalog catalog,
|
|
ISqlDialect dialect,
|
|
out SqlTagDefinition? canonical,
|
|
out SqlCatalogRejection? rejection)
|
|
{
|
|
canonical = null;
|
|
rejection = null;
|
|
|
|
if (!TryResolveRelation(definition, catalog, dialect, out var schema, out var table, out rejection))
|
|
return false;
|
|
|
|
// Every identifier-bearing field, paired with its name for the rejection message. A field the tag's
|
|
// model does not use is null and simply skipped — the planner enforces which are required.
|
|
var resolved = new Dictionary<string, string?>(StringComparer.Ordinal);
|
|
foreach (var (field, authored) in new (string Field, string? Authored)[]
|
|
{
|
|
(nameof(SqlTagDefinition.KeyColumn), definition.KeyColumn),
|
|
(nameof(SqlTagDefinition.ValueColumn), definition.ValueColumn),
|
|
(nameof(SqlTagDefinition.TimestampColumn), definition.TimestampColumn),
|
|
(nameof(SqlTagDefinition.ColumnName), definition.ColumnName),
|
|
(nameof(SqlTagDefinition.RowSelectorColumn), definition.RowSelectorColumn),
|
|
(nameof(SqlTagDefinition.RowSelectorTopByTimestamp), definition.RowSelectorTopByTimestamp),
|
|
})
|
|
{
|
|
if (string.IsNullOrWhiteSpace(authored))
|
|
{
|
|
resolved[field] = authored;
|
|
continue;
|
|
}
|
|
|
|
if (!IsRenderableIdentifier(authored, dialect))
|
|
{
|
|
rejection = new SqlCatalogRejection(definition.Name, field,
|
|
$"the authored {field} is not a usable identifier (it is over-long, or contains control " +
|
|
"or Unicode format characters); the value is withheld from this message because it " +
|
|
"cannot be safely rendered");
|
|
return false;
|
|
}
|
|
|
|
if (!catalog.TryResolveColumn(schema, table, authored, out var column))
|
|
{
|
|
rejection = new SqlCatalogRejection(definition.Name, field,
|
|
$"column '{authored}' does not exist on '{schema}.{table}' (or matches more than one " +
|
|
"column under a case-sensitive collation)");
|
|
return false;
|
|
}
|
|
|
|
resolved[field] = column;
|
|
}
|
|
|
|
canonical = definition with
|
|
{
|
|
Table = SqlCatalog.QualifiedKey(schema, table),
|
|
KeyColumn = resolved[nameof(SqlTagDefinition.KeyColumn)],
|
|
ValueColumn = resolved[nameof(SqlTagDefinition.ValueColumn)],
|
|
TimestampColumn = resolved[nameof(SqlTagDefinition.TimestampColumn)],
|
|
ColumnName = resolved[nameof(SqlTagDefinition.ColumnName)],
|
|
RowSelectorColumn = resolved[nameof(SqlTagDefinition.RowSelectorColumn)],
|
|
RowSelectorTopByTimestamp = resolved[nameof(SqlTagDefinition.RowSelectorTopByTimestamp)],
|
|
};
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Splits and resolves the authored <c>table</c> to a canonical schema + relation.</summary>
|
|
private static bool TryResolveRelation(
|
|
SqlTagDefinition definition,
|
|
SqlCatalog catalog,
|
|
ISqlDialect dialect,
|
|
out string schema,
|
|
out string table,
|
|
out SqlCatalogRejection? rejection)
|
|
{
|
|
schema = string.Empty;
|
|
table = string.Empty;
|
|
rejection = null;
|
|
const string Field = nameof(SqlTagDefinition.Table);
|
|
|
|
if (string.IsNullOrWhiteSpace(definition.Table))
|
|
{
|
|
rejection = new SqlCatalogRejection(definition.Name, Field,
|
|
"the tag has no 'table'; author the table or view to read");
|
|
return false;
|
|
}
|
|
|
|
var parts = definition.Table.Split('.');
|
|
if (parts.Length > MaxNameParts)
|
|
{
|
|
rejection = new SqlCatalogRejection(definition.Name, Field,
|
|
$"'{definition.Table}' is a {parts.Length}-part name. Only 'table' and 'schema.table' can be " +
|
|
"validated against this connection's catalog, so a cross-database or linked-server name " +
|
|
"cannot be allow-listed; expose the data through a view in this database instead");
|
|
return false;
|
|
}
|
|
|
|
var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null;
|
|
var authoredTable = parts[^1];
|
|
|
|
foreach (var part in parts)
|
|
{
|
|
if (IsRenderableIdentifier(part, dialect)) continue;
|
|
rejection = new SqlCatalogRejection(definition.Name, Field,
|
|
"the authored table name has a part that is not a usable identifier (empty, over-long, or " +
|
|
"containing control or Unicode format characters); the value is withheld from this message " +
|
|
"because it cannot be safely rendered");
|
|
return false;
|
|
}
|
|
|
|
if (!catalog.TryResolveSchema(authoredSchema, out schema))
|
|
{
|
|
rejection = new SqlCatalogRejection(definition.Name, Field,
|
|
$"schema '{authoredSchema}' does not exist (or matches more than one schema under a " +
|
|
"case-sensitive collation)");
|
|
return false;
|
|
}
|
|
|
|
if (!catalog.TryResolveTable(schema, authoredTable, out table))
|
|
{
|
|
rejection = new SqlCatalogRejection(definition.Name, Field,
|
|
$"table or view '{authoredTable}' does not exist in schema '{schema}'" +
|
|
(authoredSchema is null
|
|
? $" (the connection's default schema — qualify the name as 'schema.{authoredTable}' if it lives elsewhere)"
|
|
: string.Empty));
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when the dialect's quoting rules accept <paramref name="identifier"/> — i.e. it is safe both to
|
|
/// embed in SQL and to render into a log line or an AdminUI label.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Uses <see cref="ISqlDialect.QuoteIdentifier"/> as the charset authority rather than duplicating its
|
|
/// rules, so the two can never disagree about what a legal identifier is. The quoted result is
|
|
/// discarded: only whether it threw is interesting here.
|
|
/// </remarks>
|
|
private static bool IsRenderableIdentifier(string identifier, ISqlDialect dialect)
|
|
{
|
|
try
|
|
{
|
|
dialect.QuoteIdentifier(identifier);
|
|
return true;
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|