using Microsoft.Data.Sqlite; 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; /// /// Locks the driver's SQL-injection guarantee against a real database (): /// an authored value is bound as a parameter and can never execute, and an authored /// identifier — 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. /// Scope note — this suite deliberately tests the layer BELOW the catalog gate. Design /// §8.1's catalog gate now exists (Gitea #496): 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 — proven by /// SqlCatalogGateDriverTests. /// The tests here construct a directly, 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 quoting backstop — 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 /// (), and the seed table survives. Rewriting them to /// expect BadNodeIdUnknown would delete the only coverage the backstop has and leave the gate as a /// single point of failure. /// public sealed class SqlInjectionRegressionTests { private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15); /// The classic payload: were the key concatenated into text, this would drop the table. private const string DropTablePayload = "'; DROP TABLE TagValues; --"; [Fact] public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives() { using var fixture = new SqlitePollFixture(); var reader = NewReader(fixture, KvTag("Speed", DropTablePayload)); var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None); // Bound, not executed: it is simply a key that matches no row. snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // The table is still there, with its seeded rows — the DROP never ran. (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0); } [Fact] public async Task MaliciousKeyValue_doesNotEvenDisturbAHealthyNeighbourOnTheSamePoll() { // A hostile key in one slot must not corrupt a legitimate tag sharing the poll — it binds as its own // parameter and matches nothing; the real key still reads. using var fixture = new SqlitePollFixture(); var reader = NewReader(fixture, KvTag("Evil", DropTablePayload), KvTag("Speed", SqlitePollFixture.PresentKey)); var snapshots = await reader.ReadAsync(["Evil", "Speed"], CancellationToken.None); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue); (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0); } [Fact] public async Task MaliciousTableIdentifier_isInert_neverExecutesAndTheSeedSurvives() { using var fixture = new SqlitePollFixture(); var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); // 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", Model: SqlTagModel.KeyValue, Table: "TagValues\"; DROP TABLE TagValues; --", KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: SqlitePollFixture.PresentKey, ValueColumn: SqlitePollFixture.ValueColumn, TimestampColumn: SqlitePollFixture.TimestampColumn)); var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); // 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. (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows); } [Fact] public async Task MaliciousColumnIdentifier_isInert_neverExecutesAndTheSeedSurvives() { using var fixture = new SqlitePollFixture(); var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); // The same attack via the value column: quoted, so it becomes a nonexistent column reference; the // SELECT fails and the tag Bad-codes. Nothing executes. var reader = NewReader(fixture, new SqlTagDefinition( Name: "Evil", Model: SqlTagModel.KeyValue, Table: SqlitePollFixture.KeyValueTable, KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: SqlitePollFixture.PresentKey, ValueColumn: "num_value\"; DROP TABLE TagValues; --", TimestampColumn: SqlitePollFixture.TimestampColumn)); var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue(); (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows); } [Fact] public async Task AControlCharacterIdentifier_isRejectedByQuoting_beforeItReachesTheDatabase() { // QuoteIdentifier refuses a NUL/control-character identifier outright (it cannot name a real object). // The planner throws ArgumentException, which the reader classes as an authoring fault — // BadConfigurationError — rather than a database failure. Still inert: the table survives. using var fixture = new SqlitePollFixture(); var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable); var reader = NewReader(fixture, new SqlTagDefinition( Name: "Evil", Model: SqlTagModel.KeyValue, Table: "Tag\0Values", KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: SqlitePollFixture.PresentKey, ValueColumn: SqlitePollFixture.ValueColumn, TimestampColumn: SqlitePollFixture.TimestampColumn)); var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None); snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError); (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows); } // ---- helpers ---- private static SqlPollReader NewReader(SqlitePollFixture fixture, params SqlTagDefinition[] tags) { var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal); return new SqlPollReader( fixture.Factory, fixture.ConnectionString, new SqliteDialect(), commandTimeout: CommandTimeout, operationTimeout: OperationTimeout, maxConcurrentGroups: 4, nullIsBad: false, resolve: rawPath => table.GetValueOrDefault(rawPath)); } private static SqlTagDefinition KvTag(string name, string keyValue) => new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable, KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue, ValueColumn: SqlitePollFixture.ValueColumn, TimestampColumn: SqlitePollFixture.TimestampColumn); /// Counts rows in a table over the fixture's own connection — the direct evidence a DROP did not /// run. The table name is a test constant, never authored input, so interpolating it here is safe. private static async Task RowCountAsync(SqlitePollFixture fixture, string table) { await using var command = fixture.Connection.CreateCommand(); command.CommandText = $"SELECT COUNT(*) FROM \"{table}\""; return Convert.ToInt64(await command.ExecuteScalarAsync()); } }