Neues Testprojekt tests/ClawdDotNet.Tools.Tests. Die Angriffsfaelle aus der Bestandsaufnahme bleiben darin dauerhaft als Testfaelle dokumentiert — zusammen mit Gegenproben, damit die Fixes nicht zu streng werden und legitime Nutzung blockieren. S3 — DirectAPI gab die abgerufene URL als Quelle an das Modell zurueck, samt API-Schluessel im Query-String. Der Schluessel landete damit im Konversationskontext, wurde bei jedem Folgeschritt erneut gesendet, in ChatContext.json geschrieben und in die Logs uebernommen. UrlSanitizer maskiert sensible Query-Parameter; auch die Fehlermeldungen sind betroffen und werden bereinigt. S2 — Die Kanal-/Video-Angabe wurde ungeprueft in eine Argument-Zeichenkette fuer yt-dlp interpoliert. UseShellExecute=false verhindert Shell-Metazeichen, nicht aber Options-Injection: yt-dlp kennt die Option --exec, die beliebige Befehle ausfuehrt. Kritisch, weil der Agent untrusted Inhalte verarbeitet — eine Prompt-Injection darin konnte ihn dazu bringen, genau so einen Wert zu setzen. YouTubeUrl validiert Handles und URLs gegen die zulaessigen YouTube-Hosts und lehnt alles ab, was mit einem Bindestrich beginnt. Die Argumente gehen jetzt einzeln ueber ProcessStartInfo.ArgumentList, die Adresse steht hinter dem Optionsende-Trenner. Der ffmpeg-Aufruf wurde ebenso umgestellt. S1 — Die Tabellen-Whitelist suchte den erlaubten Namen als Teilzeichenkette irgendwo im Statement, auch in Kommentaren. Bei einer Freigabe fuer prices genuegte deshalb ein DELETE auf users mit einem Kommentar, der prices enthielt, um eine beliebige Tabelle zu loeschen. Umgekehrt galten harmlose Abfragen, die ein Schluesselwort nur als Wert enthielten, faelschlich als Schreibzugriff. SqlGuard entfernt zuerst Kommentare und String-Literale, lehnt mehrere Statements ab, bestimmt die Operation am ersten Schluesselwort und extrahiert Tabellennamen gezielt hinter FROM/JOIN/INTO/UPDATE/TABLE — inklusive kommagetrennter Listen mit Aliassen. JEDE referenzierte Tabelle muss freigegeben sein, nicht irgendeine. Ohne Whitelist wird nichts durchgelassen. Die MongoDB-Pruefung vergleicht den Collection-Namen jetzt exakt statt per Teilzeichenkette. 178 Tests gruen (91 Core, 87 Tools). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
159 lines
6.1 KiB
C#
159 lines
6.1 KiB
C#
using ClawdDotNet.Tools.Database;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Tools.Tests.Database;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|