feat(sql): implement the design §8.1 catalog identifier-validation gate (#496)
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.
This commit is contained in:
@@ -475,6 +475,99 @@ public sealed class SqlServerReadTests
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// ---- design §8.1 catalog gate (Gitea #496), against a real INFORMATION_SCHEMA ----
|
||||
|
||||
/// <summary>
|
||||
/// The gate's live proof: an authored column that does not exist is refused by the allow-list at
|
||||
/// Initialize, so it never reaches a query — and its neighbour on the same table keeps reading.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only a real SQL Server exercises the real <c>SELECT SCHEMA_NAME()</c> + <c>INFORMATION_SCHEMA</c>
|
||||
/// path the loader depends on; the SQLite-backed suites prove the logic but not that T-SQL's catalog
|
||||
/// answers the shape the loader expects.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CatalogGate_realSqlServer_rejectsAnUnknownColumn_andSparesItsNeighbour()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var bogus = Tag("Sql/Line1/Bogus", new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable),
|
||||
["keyColumn"] = SqlPollServerFixture.KeyColumn,
|
||||
["keyValue"] = SqlPollServerFixture.PresentKey,
|
||||
["valueColumn"] = "no_such_column",
|
||||
["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
|
||||
});
|
||||
|
||||
await using var driver = await StartAsync(
|
||||
KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), bogus);
|
||||
|
||||
// An authoring typo is not a database fault: the driver stays Healthy.
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
["Sql/Line1/Speed", "Sql/Line1/Bogus"], TestContext.Current.CancellationToken);
|
||||
|
||||
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-SQL object names are case-insensitive under the default collation, so a case-variant tag has
|
||||
/// always been valid and must keep working — the gate substitutes the catalog's spelling rather than
|
||||
/// rejecting the operator's.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CatalogGate_realSqlServer_acceptsACaseVariantIdentifierAndStillReads()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var upper = Tag("Sql/Line1/Speed", new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable).ToUpperInvariant(),
|
||||
["keyColumn"] = SqlPollServerFixture.KeyColumn.ToUpperInvariant(),
|
||||
["keyValue"] = SqlPollServerFixture.PresentKey,
|
||||
["valueColumn"] = SqlPollServerFixture.ValueColumn.ToUpperInvariant(),
|
||||
["timestampColumn"] = SqlPollServerFixture.TimestampColumn.ToUpperInvariant(),
|
||||
});
|
||||
|
||||
await using var driver = await StartAsync(upper);
|
||||
|
||||
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cross-database name cannot be validated from this connection's catalog, so it is refused rather
|
||||
/// than quoted into a query — the documented v1 limitation, asserted so it stays deliberate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CatalogGate_realSqlServer_rejectsAThreePartName()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var threePart = Tag("Sql/Line1/Remote", new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = $"otherdb.{SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable)}",
|
||||
["keyColumn"] = SqlPollServerFixture.KeyColumn,
|
||||
["keyValue"] = SqlPollServerFixture.PresentKey,
|
||||
["valueColumn"] = SqlPollServerFixture.ValueColumn,
|
||||
});
|
||||
|
||||
await using var driver = await StartAsync(threePart);
|
||||
|
||||
(await driver.ReadAsync(["Sql/Line1/Remote"], TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
|
||||
private void SkipUnlessLive()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The design §8.1 catalog gate end-to-end through <see cref="SqlDriver"/>, against the real SQLite
|
||||
/// catalog the poll fixture creates — real <c>ListSchemas</c>/<c>ListTables</c>/<c>ListColumns</c>
|
||||
/// round-trips, not a hand-built <see cref="SqlCatalog"/>.
|
||||
/// <para><b>The two facts that matter most here</b> are the ones a pure unit test cannot show: that a
|
||||
/// rejected tag still <em>has a node</em> and reads <c>BadNodeIdUnknown</c> (rather than silently
|
||||
/// vanishing from the address space), and that a catalog the driver cannot read faults Initialize instead
|
||||
/// of rejecting every tag.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlCatalogGateDriverTests
|
||||
{
|
||||
private const string DriverInstanceId = "sql-gate";
|
||||
private const string ConfigJson = """{"provider":"SqlServer"}""";
|
||||
|
||||
[Fact]
|
||||
public async Task A_tag_naming_a_real_table_and_columns_polls_normally()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
||||
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §8.1's specified outcome, in full: the tag is refused by the allow-list, so it never reaches a
|
||||
/// query — but its node still exists and reads <c>BadNodeIdUnknown</c>. Dropping the node instead would
|
||||
/// turn a diagnosable Bad quality into a missing address-space entry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_tag_naming_an_unknown_column_keeps_its_node_and_reads_BadNodeIdUnknown()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(
|
||||
fixture,
|
||||
KvEntry("Speed", SqlitePollFixture.PresentKey),
|
||||
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
// The driver is healthy: an authoring typo is not a database fault.
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
// The node is still materialized...
|
||||
var capture = new CapturingBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
|
||||
capture.Variables.Select(v => v.Info.FullName).ShouldBe(["Speed", "Bogus"], ignoreOrder: true);
|
||||
|
||||
// ...and it is the rejected tag — and only it — that reads BadNodeIdUnknown.
|
||||
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
|
||||
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tag_naming_an_unknown_table_reads_BadNodeIdUnknown()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(
|
||||
fixture, KvEntry("Bogus", SqlitePollFixture.PresentKey, table: "NoSuchTable"));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
(await driver.ReadAsync(["Bogus"], CancellationToken.None))
|
||||
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The injection shape #496 exists to close. Before the gate, this name was bracket-quoted into a real
|
||||
/// query that failed only once the connection was open; now it never reaches a query at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_hostile_table_name_never_reaches_a_query()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new CapturingLogger();
|
||||
await using var driver = NewDriver(
|
||||
fixture, logger,
|
||||
KvEntry("Evil", SqlitePollFixture.PresentKey, table: "TagValues\"; DROP TABLE TagValues--"));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
(await driver.ReadAsync(["Evil"], CancellationToken.None))
|
||||
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
|
||||
// The fixture's table is untouched — proven by a second tag still reading through it.
|
||||
await using var honest = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await honest.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
var snapshot = (await honest.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
||||
|
||||
logger.Entries.ShouldContain(e =>
|
||||
e.Level == LogLevel.Warning && e.Message.Contains("rejected by the catalog gate", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node that goes Bad with nothing in the log is unsupportable, so every drop names the tag, the
|
||||
/// field and the reason.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Every_rejection_is_logged_with_the_tag_the_field_and_the_reason()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new CapturingLogger();
|
||||
await using var driver = NewDriver(
|
||||
fixture, logger, KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "num_valeu"));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
var warning = logger.Entries
|
||||
.Where(e => e.Level == LogLevel.Warning)
|
||||
.Select(e => e.Message)
|
||||
.ShouldHaveSingleItem();
|
||||
warning.ShouldContain("Bogus");
|
||||
warning.ShouldContain(nameof(SqlTagDefinition.ValueColumn));
|
||||
warning.ShouldContain("num_valeu");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive authoring has always worked on SQL Server's default collation, so the gate must
|
||||
/// accept it — and it substitutes the catalog's spelling, which is what makes the emitted SQL carry
|
||||
/// catalog strings rather than operator input.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_case_variant_identifier_is_accepted_and_polls()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(
|
||||
fixture,
|
||||
KvEntry(
|
||||
"Speed", SqlitePollFixture.PresentKey,
|
||||
table: SqlitePollFixture.KeyValueTable.ToUpperInvariant(),
|
||||
valueColumn: SqlitePollFixture.ValueColumn.ToUpperInvariant()));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
||||
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver with nothing authored has nothing to validate; issuing catalog queries to prove that would
|
||||
/// be a round-trip that can only fail.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_driver_with_no_authored_tags_initializes_without_touching_the_catalog()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture);
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The fail-closed rule.</b> A catalog that cannot be read is the ABSENCE of evidence about the
|
||||
/// tags, not evidence against them. Rejecting every tag would serve a confidently-empty address space
|
||||
/// and send the operator hunting typos that do not exist; faulting Initialize instead lands
|
||||
/// <c>DriverInstanceActor</c> in Reconnecting with its retry timer running.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_catalog_that_cannot_be_read_faults_Initialize_rather_than_rejecting_every_tag()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = new SqlDriver(
|
||||
new SqlDriverOptions
|
||||
{
|
||||
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
},
|
||||
DriverInstanceId,
|
||||
new CatalogSqlOverride("SELECT this is not valid sql"),
|
||||
fixture.ConnectionString,
|
||||
factory: fixture.Factory,
|
||||
logger: NullLogger<SqlDriver>.Instance);
|
||||
|
||||
var thrown = await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
|
||||
|
||||
// The operator surface names the stage that actually failed — "reached it but could not read the
|
||||
// catalog" and "could not reach it" send an operator to different systems.
|
||||
thrown.Message.ShouldContain("catalog");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zero visible schemas is a grant problem, not an empty database, so it must fault rather than reject
|
||||
/// every tag for an authoring fault the operator does not have.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_catalog_reporting_no_schemas_at_all_faults_Initialize()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = new SqlDriver(
|
||||
new SqlDriverOptions
|
||||
{
|
||||
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
},
|
||||
DriverInstanceId,
|
||||
new CatalogSqlOverride("SELECT 'x' AS TABLE_SCHEMA WHERE 1 = 0"),
|
||||
fixture.ConnectionString,
|
||||
factory: fixture.Factory,
|
||||
logger: NullLogger<SqlDriver>.Instance);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Delegates every member to <see cref="SqliteDialect"/> except <see cref="ListSchemasSql"/>, so the
|
||||
/// catalog-load failure modes can be driven against an otherwise-real dialect.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and even if it were not,
|
||||
/// <c>new</c>-hiding a property would leave interface dispatch calling the base — the substitution
|
||||
/// would silently not happen and both tests below would pass for the wrong reason.
|
||||
/// </remarks>
|
||||
private sealed class CatalogSqlOverride(string listSchemasSql) : ISqlDialect
|
||||
{
|
||||
private static readonly SqliteDialect Inner = new();
|
||||
|
||||
public SqlProvider Provider => Inner.Provider;
|
||||
|
||||
public System.Data.Common.DbProviderFactory Factory => Inner.Factory;
|
||||
|
||||
public string LivenessSql => Inner.LivenessSql;
|
||||
|
||||
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string ListSchemasSql { get; } = listSchemasSql;
|
||||
|
||||
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
|
||||
|
||||
public string ListTablesSql => Inner.ListTablesSql;
|
||||
|
||||
public string ListColumnsSql => Inner.ListColumnsSql;
|
||||
|
||||
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
|
||||
|
||||
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
|
||||
}
|
||||
|
||||
private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
|
||||
=> NewDriver(fixture, new CapturingLogger(), rawTags);
|
||||
|
||||
private static SqlDriver NewDriver(
|
||||
SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
|
||||
=> new(
|
||||
new SqlDriverOptions
|
||||
{
|
||||
RawTags = rawTags,
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
},
|
||||
DriverInstanceId,
|
||||
new SqliteDialect(),
|
||||
fixture.ConnectionString,
|
||||
factory: fixture.Factory,
|
||||
logger: logger);
|
||||
|
||||
/// <summary>One authored raw tag, with the table and value column overridable so the gate can be exercised.</summary>
|
||||
private static RawTagEntry KvEntry(
|
||||
string rawPath,
|
||||
string keyValue,
|
||||
string? table = null,
|
||||
string? valueColumn = null)
|
||||
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
|
||||
{
|
||||
"driver": "Sql",
|
||||
"model": "KeyValue",
|
||||
"table": "{{(table ?? SqlitePollFixture.KeyValueTable).Replace("\"", "\\\"", StringComparison.Ordinal)}}",
|
||||
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
|
||||
"keyValue": "{{keyValue}}",
|
||||
"valueColumn": "{{valueColumn ?? SqlitePollFixture.ValueColumn}}",
|
||||
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
|
||||
}
|
||||
"""), WriteIdempotent: false);
|
||||
|
||||
/// <summary>Records everything the driver streams into the address space.</summary>
|
||||
private sealed class CapturingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>The variables registered, in order.</summary>
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new Handle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullReference) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullReference;
|
||||
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
|
||||
|
||||
private sealed class Sink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records every log record, level + rendered message.</summary>
|
||||
private sealed class CapturingLogger : ILogger<SqlDriver>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static NullScope Instance { get; } = new();
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The design §8.1 catalog gate (Gitea #496), tested as a pure function over a hand-built
|
||||
/// <see cref="SqlCatalog"/>. The end-to-end behaviour against a real catalog — and the proof that a
|
||||
/// rejected tag keeps its node and publishes <c>BadNodeIdUnknown</c> — lives in
|
||||
/// <see cref="SqlCatalogGateDriverTests"/>.
|
||||
/// </summary>
|
||||
public sealed class SqlCatalogGateTests
|
||||
{
|
||||
private static readonly SqliteDialect Dialect = new();
|
||||
|
||||
/// <summary>A catalog with one schema, two relations, and deliberately mixed-case column spellings.</summary>
|
||||
private static SqlCatalog Catalog(string defaultSchema = "dbo") => new(
|
||||
defaultSchema,
|
||||
["dbo", "mes"],
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
["dbo"] = ["TagValues", "LatestStatus"],
|
||||
["mes"] = ["Orders"],
|
||||
},
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
["dbo.TagValues"] = ["tag_name", "num_value", "sample_ts"],
|
||||
["dbo.LatestStatus"] = ["station_id", "oven_temp", "pressure", "sample_ts"],
|
||||
["mes.Orders"] = ["order_id", "qty"],
|
||||
});
|
||||
|
||||
private static SqlTagDefinition KeyValueTag(
|
||||
string table = "dbo.TagValues",
|
||||
string keyColumn = "tag_name",
|
||||
string valueColumn = "num_value",
|
||||
string? timestampColumn = "sample_ts") =>
|
||||
new("plant/sql/Speed", SqlTagModel.KeyValue, table,
|
||||
KeyColumn: keyColumn, KeyValue: "Line1.Speed",
|
||||
ValueColumn: valueColumn, TimestampColumn: timestampColumn);
|
||||
|
||||
private static SqlCatalogGateResult Apply(params SqlTagDefinition[] tags) =>
|
||||
SqlCatalogGate.Apply(tags, Catalog(), Dialect);
|
||||
|
||||
[Fact]
|
||||
public void A_fully_resolvable_tag_is_accepted()
|
||||
{
|
||||
var result = Apply(KeyValueTag());
|
||||
|
||||
result.Rejected.ShouldBeEmpty();
|
||||
result.Accepted.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The heart of §8.1: what reaches the planner must be a string the catalog gave us, not the string an
|
||||
/// operator typed. Authoring every identifier in the wrong case proves the substitution actually
|
||||
/// happens rather than the input merely being waved through.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Accepted_identifiers_are_rewritten_to_the_catalogs_own_spelling()
|
||||
{
|
||||
var authored = KeyValueTag(
|
||||
table: "DBO.TAGVALUES", keyColumn: "TAG_NAME", valueColumn: "Num_Value", timestampColumn: "SAMPLE_TS");
|
||||
|
||||
var accepted = Apply(authored).Accepted.ShouldHaveSingleItem();
|
||||
|
||||
accepted.Table.ShouldBe("dbo.TagValues");
|
||||
accepted.KeyColumn.ShouldBe("tag_name");
|
||||
accepted.ValueColumn.ShouldBe("num_value");
|
||||
accepted.TimestampColumn.ShouldBe("sample_ts");
|
||||
// Identity and bound VALUES are untouched — the gate rewrites identifiers only.
|
||||
accepted.Name.ShouldBe(authored.Name);
|
||||
accepted.KeyValue.ShouldBe(authored.KeyValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unqualified_table_resolves_in_the_default_schema()
|
||||
{
|
||||
var accepted = Apply(KeyValueTag(table: "TagValues")).Accepted.ShouldHaveSingleItem();
|
||||
|
||||
accepted.Table.ShouldBe("dbo.TagValues");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guessing <c>dbo</c> would be a silent lie on an estate that maps service accounts to their own
|
||||
/// default schema, so the gate must resolve an unqualified name in whatever schema the server reports.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_unqualified_table_follows_a_non_dbo_default_schema()
|
||||
{
|
||||
var catalog = Catalog(defaultSchema: "mes");
|
||||
|
||||
var result = SqlCatalogGate.Apply([KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null)], catalog, Dialect);
|
||||
|
||||
result.Accepted.ShouldHaveSingleItem().Table.ShouldBe("mes.Orders");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_table_rejects_the_tag()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(table: "dbo.NoSuchTable")).Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.RawPath.ShouldBe("plant/sql/Speed");
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
|
||||
rejection.Reason.ShouldContain("NoSuchTable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_schema_rejects_the_tag()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(table: "nope.TagValues")).Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
|
||||
rejection.Reason.ShouldContain("nope");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_column_rejects_the_tag_and_names_the_field()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(valueColumn: "no_such_column")).Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
|
||||
rejection.Reason.ShouldContain("no_such_column");
|
||||
rejection.Reason.ShouldContain("dbo.TagValues");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A table in the right catalog but the wrong schema must not resolve — otherwise the gate would
|
||||
/// allow-list a column set from a relation the query will never read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_table_from_another_schema_does_not_resolve_unqualified()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null))
|
||||
.Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cross-database or linked-server name addresses a catalog this connection cannot enumerate, so it
|
||||
/// cannot be allow-listed. Rejecting is the fail-closed answer, and the message says what to do instead.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_three_part_name_is_rejected_with_an_actionable_message()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(table: "otherdb.dbo.TagValues")).Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
|
||||
rejection.Reason.ShouldContain("3-part");
|
||||
rejection.Reason.ShouldContain("view");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The injection shape the whole gate exists for: a hostile identifier must be refused by the
|
||||
/// allow-list, not merely quoted into a query against a nonexistent object.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("TagValues]; DROP TABLE TagValues--")]
|
||||
[InlineData("'; DROP TABLE TagValues--")]
|
||||
[InlineData("TagValues WHERE 1=1 OR 1=1")]
|
||||
public void A_hostile_table_name_is_rejected_by_the_allow_list(string table)
|
||||
{
|
||||
Apply(KeyValueTag(table: table)).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.Table));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("num_value]; DROP TABLE TagValues--")]
|
||||
[InlineData("(SELECT password FROM users)")]
|
||||
public void A_hostile_column_name_is_rejected_by_the_allow_list(string column)
|
||||
{
|
||||
Apply(KeyValueTag(valueColumn: column)).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A name carrying a control or Unicode format character cannot be safely rendered into a log line
|
||||
/// (the Trojan-Source class, CVE-2021-42574), so the gate rejects it <em>without echoing it</em> — the
|
||||
/// charset check runs before the catalog lookup precisely so no such string can reach a message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Driven against <see cref="SqlServerDialect"/>, not the test-only <see cref="SqliteDialect"/>: the
|
||||
/// charset rules belong to the dialect (the gate delegates to
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> rather than duplicating them), and SQLite's rules
|
||||
/// deliberately stop at control characters. Asserting SQL Server's rules means using SQL Server's
|
||||
/// dialect — the first draft of this test asserted them against SQLite's and failed for that reason.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("num\u0000value")] // Cc — embedded NUL
|
||||
[InlineData("num\u0009value")] // Cc — tab; truncates or corrupts a logged statement
|
||||
[InlineData("num\u202Evalue")] // Cf — right-to-left override
|
||||
[InlineData("num\u200Bvalue")] // Cf — zero-width space
|
||||
public void An_unrenderable_identifier_is_rejected_without_being_echoed(string column)
|
||||
{
|
||||
var result = SqlCatalogGate.Apply(
|
||||
[KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect());
|
||||
|
||||
var rejection = result.Rejected.ShouldHaveSingleItem();
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
|
||||
rejection.Reason.ShouldContain("withheld");
|
||||
rejection.Reason.ShouldNotContain(column);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An over-long name cannot name a real object and is likewise withheld, rather than pasting an
|
||||
/// unbounded slab of operator input into a log line.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_over_long_identifier_is_rejected_without_being_echoed()
|
||||
{
|
||||
var column = new string('x', SqlServerDialect.MaxIdentifierLength + 1);
|
||||
|
||||
var result = SqlCatalogGate.Apply(
|
||||
[KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect());
|
||||
|
||||
result.Rejected.ShouldHaveSingleItem().Reason.ShouldContain("withheld");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The complement, and the reason the charset check is not simply "withhold everything": a name that
|
||||
/// IS safely renderable gets echoed, because an operator hunting a typo has to see what they wrote.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_renderable_but_unknown_identifier_IS_echoed_so_the_typo_is_findable()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(valueColumn: "num_valeu")).Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.Reason.ShouldContain("num_valeu");
|
||||
rejection.Reason.ShouldNotContain("withheld");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On a case-sensitive collation a relation may legitimately carry both <c>Value</c> and <c>value</c>.
|
||||
/// Picking one would publish a different column's data under the operator's node, so the only safe
|
||||
/// answer is to refuse — but an EXACT match must still win, or a valid config would break.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_ambiguous_case_insensitive_column_match_is_rejected_but_an_exact_match_still_wins()
|
||||
{
|
||||
var catalog = new SqlCatalog(
|
||||
"dbo",
|
||||
["dbo"],
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal) { ["dbo"] = ["T"] },
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
["dbo.T"] = ["k", "Value", "value"],
|
||||
});
|
||||
|
||||
var ambiguous = new SqlTagDefinition(
|
||||
"p/Ambiguous", SqlTagModel.KeyValue, "dbo.T",
|
||||
KeyColumn: "k", KeyValue: "x", ValueColumn: "VALUE");
|
||||
SqlCatalogGate.Apply([ambiguous], catalog, Dialect).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
|
||||
|
||||
var exact = ambiguous with { Name = "p/Exact", ValueColumn = "value" };
|
||||
SqlCatalogGate.Apply([exact], catalog, Dialect).Accepted.ShouldHaveSingleItem()
|
||||
.ValueColumn.ShouldBe("value");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One operator typo must not stop the other tags on the same database — the same rule the tag-table
|
||||
/// build already follows for a malformed blob.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_rejected_tag_does_not_take_its_healthy_neighbours_with_it()
|
||||
{
|
||||
var good = KeyValueTag() with { Name = "plant/sql/Good" };
|
||||
var bad = KeyValueTag(valueColumn: "typo") with { Name = "plant/sql/Bad" };
|
||||
|
||||
var result = Apply(good, bad);
|
||||
|
||||
result.Accepted.ShouldHaveSingleItem().Name.ShouldBe("plant/sql/Good");
|
||||
result.Rejected.ShouldHaveSingleItem().RawPath.ShouldBe("plant/sql/Bad");
|
||||
}
|
||||
|
||||
/// <summary>Every identifier-bearing field is checked, not just the two the key-value model happens to use.</summary>
|
||||
[Fact]
|
||||
public void The_wide_row_models_identifier_fields_are_validated_too()
|
||||
{
|
||||
var selectorTypo = new SqlTagDefinition(
|
||||
"p/Oven", SqlTagModel.WideRow, "dbo.LatestStatus",
|
||||
ColumnName: "oven_temp", RowSelectorColumn: "no_such_selector", RowSelectorValue: "7");
|
||||
Apply(selectorTypo).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorColumn));
|
||||
|
||||
var orderTypo = new SqlTagDefinition(
|
||||
"p/Newest", SqlTagModel.WideRow, "dbo.LatestStatus",
|
||||
ColumnName: "oven_temp", RowSelectorTopByTimestamp: "no_such_ts");
|
||||
Apply(orderTypo).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorTopByTimestamp));
|
||||
|
||||
var columnTypo = new SqlTagDefinition(
|
||||
"p/Bad", SqlTagModel.WideRow, "dbo.LatestStatus",
|
||||
ColumnName: "no_such_column", RowSelectorColumn: "station_id", RowSelectorValue: "7");
|
||||
Apply(columnTypo).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.ColumnName));
|
||||
|
||||
var ok = new SqlTagDefinition(
|
||||
"p/Ok", SqlTagModel.WideRow, "dbo.LatestStatus",
|
||||
ColumnName: "OVEN_TEMP", RowSelectorColumn: "STATION_ID", RowSelectorValue: "7");
|
||||
var accepted = Apply(ok).Accepted.ShouldHaveSingleItem();
|
||||
accepted.ColumnName.ShouldBe("oven_temp");
|
||||
accepted.RowSelectorColumn.ShouldBe("station_id");
|
||||
accepted.RowSelectorValue.ShouldBe("7"); // a bound value, never canonicalized
|
||||
}
|
||||
|
||||
/// <summary>An absent optional identifier is not a rejection — only a present, unresolvable one is.</summary>
|
||||
[Fact]
|
||||
public void An_absent_optional_timestamp_column_is_left_alone()
|
||||
{
|
||||
var accepted = Apply(KeyValueTag(timestampColumn: null)).Accepted.ShouldHaveSingleItem();
|
||||
|
||||
accepted.TimestampColumn.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
/// <em>identifier</em> — which cannot be parameterized in SQL — is dialect-quoted so a hostile one becomes
|
||||
/// an inert reference to a nonexistent object rather than executable text. If these pass, a malicious tag
|
||||
/// cannot alter the database; the seed table is intact after every hostile poll.
|
||||
/// <para><b>Scope note — what this suite does NOT assume.</b> Design §8.1 also specifies a catalog gate:
|
||||
/// validate every authored table/column against <c>INFORMATION_SCHEMA</c> before quoting it, so an unknown
|
||||
/// identifier <em>rejects the tag</em>. <b>No such gate exists in the driver yet</b> (see the "Not yet
|
||||
/// implemented" note on <see cref="ISqlDialect"/>), and no task in this workstream builds one. So a hostile
|
||||
/// identifier here is <b>not</b> rejected as <c>BadNodeIdUnknown</c> — it is bracket-/double-quoted into a
|
||||
/// single nonexistent identifier, the query then fails after the connection opened, and the tag Bad-codes
|
||||
/// as a query failure (<see cref="SqlStatusCodes.BadCommunicationError"/>). This suite proves the payload
|
||||
/// is <em>inert</em> — it does not execute and the table survives — which is the guarantee the code
|
||||
/// actually makes. The catalog gate is a separate follow-up; a test that pretended it existed would be
|
||||
/// asserting fiction.</para>
|
||||
/// <para><b>Scope note — this suite deliberately tests the layer BELOW the catalog gate.</b> Design
|
||||
/// §8.1's catalog gate now exists (Gitea #496): <see cref="SqlCatalogGate"/> validates every authored
|
||||
/// table/column against the live catalog at Initialize, so in the assembled driver a hostile identifier
|
||||
/// is rejected up front and its tag reads <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — proven by
|
||||
/// <c>SqlCatalogGateDriverTests</c>.</para>
|
||||
/// <para>The tests here construct a <see cref="SqlPollReader"/> <b>directly</b>, with hand-built
|
||||
/// definitions that never pass through the gate, and that is the point: defence in depth is only worth
|
||||
/// the name if each layer holds on its own. These assertions pin the <em>quoting backstop</em> — that
|
||||
/// even with the allow-list bypassed entirely, a hostile identifier becomes an inert reference to a
|
||||
/// nonexistent object, the query fails after the connection opened, the tag Bad-codes as a query failure
|
||||
/// (<see cref="SqlStatusCodes.BadCommunicationError"/>), and the seed table survives. Rewriting them to
|
||||
/// expect <c>BadNodeIdUnknown</c> would delete the only coverage the backstop has and leave the gate as a
|
||||
/// single point of failure.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlInjectionRegressionTests
|
||||
{
|
||||
@@ -68,9 +71,11 @@ public sealed class SqlInjectionRegressionTests
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);
|
||||
|
||||
// A table name carrying a statement terminator + DROP. There is NO catalog gate, so this is not
|
||||
// rejected up front — it is quoted into one nonexistent identifier. The query fails (no such table),
|
||||
// the connection having opened, so the tag Bad-codes as a query failure. The DROP never runs.
|
||||
// A table name carrying a statement terminator + DROP. The reader is driven directly, so the
|
||||
// catalog gate never sees it and the quoting backstop is on its own: the name is quoted into one
|
||||
// nonexistent identifier, the query fails (no such table) with the connection already open, so the
|
||||
// tag Bad-codes as a query failure. The DROP never runs. In the assembled driver the gate rejects
|
||||
// this first and the tag reads BadNodeIdUnknown instead — see SqlCatalogGateDriverTests.
|
||||
var reader = NewReader(fixture,
|
||||
new SqlTagDefinition(
|
||||
Name: "Evil",
|
||||
@@ -83,7 +88,7 @@ public sealed class SqlInjectionRegressionTests
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);
|
||||
|
||||
// Bad-coded — but as a QUERY failure, not BadNodeIdUnknown (there is no identifier-catalog gate).
|
||||
// Bad-coded as a QUERY failure, not BadNodeIdUnknown — this reader was built without the gate.
|
||||
SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError);
|
||||
// The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched.
|
||||
|
||||
@@ -64,6 +64,12 @@ public sealed class SqliteDialect : ISqlDialect
|
||||
/// </summary>
|
||||
public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA";
|
||||
|
||||
/// <summary>
|
||||
/// SQLite has no per-principal default schema, so an unqualified name always resolves in
|
||||
/// <see cref="MainSchema"/> — the same single schema <see cref="ListSchemasSql"/> reports.
|
||||
/// </summary>
|
||||
public string DefaultSchemaSql => $"SELECT '{MainSchema}'";
|
||||
|
||||
/// <summary>
|
||||
/// Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the
|
||||
/// internal <c>sqlite_*</c> objects filtered out and the type folded onto the
|
||||
|
||||
Reference in New Issue
Block a user