using ClawdDotNet.Tools.Database; using Shouldly; namespace ClawdDotNet.Tools.Tests.Database; /// /// S1 aus der Bestandsaufnahme: Die Tabellen-Whitelist suchte den erlaubten Namen als /// Teilzeichenkette irgendwo im Statement — auch in einem Kommentar. Bei /// allowedTables ["prices"] genügte deshalb "DELETE FROM users -- prices", um eine /// beliebige andere Tabelle zu löschen. /// /// Die Gegenproben sind genauso wichtig wie die Angriffe: Ein zu strenger Filter, der /// legitime Abfragen blockiert, wäre im Betrieb genauso unbrauchbar. /// public sealed class SqlGuardTests { private static readonly string[] Allowed = ["prices", "signals", "portfolio"]; private static SqlInspection Inspect(string sql) => SqlGuard.Inspect(sql, Allowed); // ═══════════════════════════════════════════════════════════ // Angriffe — alle müssen abgelehnt werden // ═══════════════════════════════════════════════════════════ [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 Kommentare_koennen_keine_erlaubte_Tabelle_vortaeuschen(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_Literale_koennen_keine_erlaubte_Tabelle_vortaeuschen(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 Mehrere_Statements_werden_abgelehnt(string sql) { var result = Inspect(sql); result.IsValid.ShouldBeFalse(); result.Error.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 Ein_einziger_unerlaubter_Join_reicht_zur_Ablehnung(string sql) { // Entscheidend: JEDE referenzierte Tabelle muss erlaubt sein, nicht irgendeine. var result = Inspect(sql); result.IsValid.ShouldBeFalse(); result.Error.ShouldContain("users"); } [Fact] public void Ohne_Whitelist_wird_nichts_durchgelassen() { SqlGuard.Inspect("SELECT * FROM prices", []).IsValid.ShouldBeFalse(); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void Leere_Statements_werden_abgelehnt(string? sql) { SqlGuard.Inspect(sql, Allowed).IsValid.ShouldBeFalse(); } // ═══════════════════════════════════════════════════════════ // Gegenproben — legitime Abfragen müssen durchgehen // ═══════════════════════════════════════════════════════════ [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 Erlaubte_Leseabfragen_gehen_durch(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 Harmlose_Schluesselwoerter_in_Werten_gelten_nicht_als_Schreibzugriff(string sql) { // Der alte Substring-Test stufte diese Abfragen fälschlich als Schreib- bzw. // Strukturzugriff ein und blockierte sie für ReadOnly-Agenten. 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 Die_Operation_wird_am_ersten_Schluesselwort_bestimmt(string sql, SqlOperation expected) { var result = Inspect(sql); result.Operation.ShouldBe(expected); result.IsValid.ShouldBeTrue(result.Error); } [Fact] public void Schema_qualifizierte_Namen_werden_erkannt() { var result = Inspect("SELECT * FROM crypto.prices WHERE id = 1"); result.IsValid.ShouldBeTrue(result.Error); } [Fact] public void Der_Fehlertext_nennt_die_abgelehnte_Tabelle_und_die_erlaubten() { var result = Inspect("SELECT * FROM geheim"); result.Error.ShouldContain("geheim"); result.Error.ShouldContain("prices"); } [Fact] public void Escaptes_Apostroph_im_Literal_bricht_die_Erkennung_nicht() { var result = Inspect("SELECT * FROM prices WHERE name = 'O''Brien'"); result.IsValid.ShouldBeTrue(result.Error); } }