using ClawdDotNet.Tools.Database; using Shouldly; namespace ClawdDotNet.Tools.Tests.Database; /// /// S1 from the inventory: the table whitelist looked for the allowed name as a /// substring anywhere in the statement — including in a comment. With /// allowedTables ["prices"] therefore "DELETE FROM users -- prices" was enough to delete /// any other table. /// /// The counter-checks are just as important as the attacks: a filter that is too strict and /// blocks legitimate queries would be just as useless in operation. /// public sealed class SqlGuardTests { private static readonly string[] Allowed = ["prices", "signals", "portfolio"]; private static SqlInspection Inspect(string sql) => SqlGuard.Inspect(sql, Allowed); // ═══════════════════════════════════════════════════════════ // Attacks — all must be rejected // ═══════════════════════════════════════════════════════════ // ─── #610: repeatable creation and structure queries ─── [Theory] [InlineData("CREATE TABLE IF NOT EXISTS prices (id INT)", SqlOperation.Schema)] [InlineData("DROP TABLE IF EXISTS prices", SqlOperation.Schema)] [InlineData("DESCRIBE prices", SqlOperation.Read)] [InlineData("DESC prices", SqlOperation.Read)] [InlineData("SHOW COLUMNS FROM prices", SqlOperation.Read)] public void IF_NOT_EXISTS_and_structure_queries_name_the_right_table(string sql, SqlOperation operation) { var result = Inspect(sql); result.IsValid.ShouldBeTrue(result.Error); result.Operation.ShouldBe(operation); result.Tables.ShouldBe(["prices"]); } [Theory] [InlineData("CREATE TABLE IF NOT EXISTS users (id INT)")] [InlineData("DROP TABLE IF EXISTS users")] [InlineData("DESCRIBE users")] public void IF_EXISTS_and_DESCRIBE_open_no_foreign_table(string sql) => Inspect(sql).IsValid.ShouldBeFalse(); [Theory] [InlineData("DELETE FROM users -- prices")] [InlineData("DELETE FROM users /* prices */")] [InlineData("DROP TABLE users -- prices")] [InlineData("UPDATE users SET admin=1 -- prices")] [InlineData("SELECT * FROM users # prices")] public void Comments_cannot_fake_an_allowed_table(string sql) { var result = Inspect(sql); result.IsValid.ShouldBeFalse($"'{sql}' muss abgelehnt werden"); } [Theory] [InlineData("SELECT * FROM users WHERE note = 'prices'")] [InlineData("INSERT INTO users (name) VALUES ('prices')")] [InlineData("SELECT * FROM users WHERE a = \"prices\"")] public void String_literals_cannot_fake_an_allowed_table(string sql) { Inspect(sql).IsValid.ShouldBeFalse(); } [Theory] [InlineData("SELECT * FROM prices; DROP TABLE users")] [InlineData("SELECT * FROM prices; DELETE FROM users")] [InlineData("SELECT 1; SELECT 2")] public void Multiple_statements_are_rejected(string sql) { var result = Inspect(sql); result.IsValid.ShouldBeFalse(); result.Error.ShouldNotBeNull().ShouldContain("Mehrere Statements"); } [Theory] [InlineData("SELECT * FROM prices JOIN users ON users.id = prices.uid")] [InlineData("SELECT * FROM prices, users")] [InlineData("INSERT INTO users SELECT * FROM prices")] public void A_single_disallowed_join_is_enough_for_rejection(string sql) { // Crucial: EVERY referenced table must be allowed, not just any one. var result = Inspect(sql); result.IsValid.ShouldBeFalse(); result.Error.ShouldNotBeNull().ShouldContain("users"); } [Fact] public void Without_whitelist_nothing_is_let_through() { SqlGuard.Inspect("SELECT * FROM prices", []).IsValid.ShouldBeFalse(); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void Empty_statements_are_rejected(string? sql) { SqlGuard.Inspect(sql, Allowed).IsValid.ShouldBeFalse(); } // ═══════════════════════════════════════════════════════════ // Counter-checks — legitimate queries must get through // ═══════════════════════════════════════════════════════════ [Theory] [InlineData("SELECT * FROM prices")] [InlineData("SELECT ticker, close FROM prices WHERE ticker = 'NVDA' ORDER BY ts DESC LIMIT 10")] [InlineData("SELECT * FROM prices JOIN signals ON signals.ticker = prices.ticker")] [InlineData("select * from PRICES")] [InlineData("SELECT * FROM prices;")] public void Allowed_read_queries_go_through(string sql) { var result = Inspect(sql); result.IsValid.ShouldBeTrue(result.Error); result.Operation.ShouldBe(SqlOperation.Read); } [Theory] [InlineData("SELECT * FROM prices WHERE note = 'update'")] [InlineData("SELECT * FROM prices WHERE kommentar = 'bitte delete beachten'")] [InlineData("SELECT * FROM prices WHERE aktion = 'drop'")] public void Harmless_keywords_in_values_do_not_count_as_write_access(string sql) { // The old substring test wrongly classified these queries as write or // structure access and blocked them for read-only agents. var result = Inspect(sql); result.IsValid.ShouldBeTrue(result.Error); result.Operation.ShouldBe(SqlOperation.Read); } [Theory] [InlineData("UPDATE prices SET close = 1 WHERE id = 2", SqlOperation.Write)] [InlineData("INSERT INTO signals (ticker) VALUES ('NVDA')", SqlOperation.Write)] [InlineData("DELETE FROM portfolio WHERE id = 5", SqlOperation.Write)] [InlineData("DROP TABLE prices", SqlOperation.Schema)] [InlineData("ALTER TABLE prices ADD COLUMN x INT", SqlOperation.Schema)] [InlineData("TRUNCATE TABLE prices", SqlOperation.Schema)] public void The_operation_is_determined_by_the_first_keyword(string sql, SqlOperation expected) { var result = Inspect(sql); result.Operation.ShouldBe(expected); result.IsValid.ShouldBeTrue(result.Error); } [Fact] public void Schema_qualified_names_are_recognised() { var result = Inspect("SELECT * FROM crypto.prices WHERE id = 1"); result.IsValid.ShouldBeTrue(result.Error); } [Fact] public void The_error_text_names_the_rejected_table_and_the_allowed_ones() { var result = Inspect("SELECT * FROM geheim"); result.Error.ShouldNotBeNull().ShouldContain("geheim"); result.Error.ShouldNotBeNull().ShouldContain("prices"); } [Fact] public void Escaped_apostrophe_in_the_literal_does_not_break_detection() { var result = Inspect("SELECT * FROM prices WHERE name = 'O''Brien'"); result.IsValid.ShouldBeTrue(result.Error); } }