a55b7e51ca
Locks the driver's injection guarantee against the SQLite fixture. An
authored VALUE ('; DROP TABLE TagValues; --) binds as a DbParameter and can
only ever be a key that matches no row (BadNoData); the seed table survives.
A hostile IDENTIFIER in table/column position is dialect-quoted into a
single nonexistent identifier — inert — and the payload never executes.
Scope, stated plainly: design §8.1 also specifies a catalog gate (validate
an authored identifier against INFORMATION_SCHEMA, reject an unknown one as
BadNodeIdUnknown). That gate does NOT exist in the driver yet and no task
here builds it, so a hostile identifier is not rejected up front — it is
quoted, the query fails after the connection opened, and the tag Bad-codes
as a query failure (BadCommunicationError), not BadNodeIdUnknown. These
tests assert what the code actually guarantees — the payload is inert and
the table intact — rather than a catalog gate that isn't there. The gate is
a tracked follow-up.
No implementation change: the reader already binds correctly (Task 7); this
suite pins it. Falsifiability-checked — forcing a real DROP before the
row-count assertion turns it red, so "table survives" is load-bearing.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
169 lines
8.8 KiB
C#
169 lines
8.8 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Locks the driver's SQL-injection guarantee against a real database (<see cref="SqlitePollFixture"/>):
|
|
/// an authored <em>value</em> is bound as a parameter and can never execute, and an authored
|
|
/// <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>
|
|
/// </summary>
|
|
public sealed class SqlInjectionRegressionTests
|
|
{
|
|
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
|
|
private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15);
|
|
|
|
/// <summary>The classic payload: were the key concatenated into text, this would drop the table.</summary>
|
|
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. 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.
|
|
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 — but as a QUERY failure, not BadNodeIdUnknown (there is no identifier-catalog 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);
|
|
|
|
/// <summary>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.</summary>
|
|
private static async Task<long> RowCountAsync(SqlitePollFixture fixture, string table)
|
|
{
|
|
await using var command = fixture.Connection.CreateCommand();
|
|
command.CommandText = $"SELECT COUNT(*) FROM \"{table}\"";
|
|
return Convert.ToInt64(await command.ExecuteScalarAsync());
|
|
}
|
|
}
|