diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx
index ccfa616..5bbb274 100644
--- a/ClawdDotNet.slnx
+++ b/ClawdDotNet.slnx
@@ -17,6 +17,7 @@
+
diff --git a/docs/Bestandsaufnahme-2026-07.md b/docs/Bestandsaufnahme-2026-07.md
index e16b787..6347135 100644
--- a/docs/Bestandsaufnahme-2026-07.md
+++ b/docs/Bestandsaufnahme-2026-07.md
@@ -528,8 +528,8 @@ Siehe K3.
2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben
2b. ~~B14 System-Prompt-Duplikat~~ ✅ behoben
3. ~~B2 Race Condition im Chat-Kontext~~ ✅ behoben
-4. S2 yt-dlp-Injection
-5. S3 API-Key-Leak
+4. ~~S2 yt-dlp-Injection~~ ✅ behoben
+5. ~~S3 API-Key-Leak~~ ✅ behoben
**Kurzfristig — größter Nutzen pro Aufwand**
6. ~~T1 Prompt-Caching~~ ✅ umgesetzt (inkl. T9 `cached_tokens`)
@@ -542,7 +542,7 @@ Siehe K3.
11. T4 Proaktiv statt reaktiv kompaktieren
**Mittelfristig — Fundament**
-11. S1 DatabaseTool absichern
+11. ~~S1 DatabaseTool absichern~~ ✅ behoben (`SqlGuard`)
12. Memory-Tool (K1)
13. F-A1 Freigabe-Workflow + F-A2 Audit-Log
14. K3 Testprojekt
diff --git a/src/ClawdDotNet.Tools.Database/DatabaseTool.cs b/src/ClawdDotNet.Tools.Database/DatabaseTool.cs
index e0331f4..6b3533f 100644
--- a/src/ClawdDotNet.Tools.Database/DatabaseTool.cs
+++ b/src/ClawdDotNet.Tools.Database/DatabaseTool.cs
@@ -95,22 +95,22 @@ public sealed class DatabaseTool : IAgentTool
var accessLevel = GetAccessLevel(context);
- // Sicherheitsprüfungen
- if (IsAdminAttempt(sql))
- {
- if (accessLevel != DatabaseAccessLevel.Admin)
- return ToolResult.Fail("Sicherheitsfehler: Strukturänderungen (DDL) sind für diesen Agenten nicht erlaubt.");
- }
- else if (IsWriteAttempt(sql))
- {
- if (accessLevel == DatabaseAccessLevel.ReadOnly)
- return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff ist für diesen Agenten deaktiviert (ReadOnly).");
- }
+ // Statement analysieren: Kommentare, Mehrfach-Statements und String-Literale
+ // werden dabei behandelt, bevor Operation und Tabellen bestimmt werden.
+ var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
- // Tabellen-Whitelist-Prüfung
- if (!IsTableAllowed(sql, context))
+ if (!inspection.IsValid)
+ return ToolResult.Fail($"Sicherheitsfehler: {inspection.Error}");
+
+ switch (inspection.Operation)
{
- return ToolResult.Fail("Sicherheitsfehler: Zugriff auf eine oder mehrere Tabellen im Statement ist nicht erlaubt.");
+ case SqlOperation.Schema when accessLevel != DatabaseAccessLevel.Admin:
+ return ToolResult.Fail(
+ "Sicherheitsfehler: Strukturänderungen (DDL) sind für diesen Agenten nicht erlaubt.");
+
+ case SqlOperation.Write when accessLevel == DatabaseAccessLevel.ReadOnly:
+ return ToolResult.Fail(
+ "Sicherheitsfehler: Schreibzugriff ist für diesen Agenten deaktiviert (ReadOnly).");
}
using DbConnection conn = type switch
@@ -152,7 +152,7 @@ public sealed class DatabaseTool : IAgentTool
var collectionName = input.TryGetProperty("collection", out var c) ? c.GetString() : null;
if (string.IsNullOrWhiteSpace(collectionName)) return ToolResult.Fail("'collection' ist erforderlich.");
- if (!IsTableAllowed(collectionName, context))
+ if (!IsCollectionAllowed(collectionName, context))
{
return ToolResult.Fail($"Sicherheitsfehler: Zugriff auf Collection '{collectionName}' ist nicht erlaubt.");
}
@@ -186,18 +186,6 @@ public sealed class DatabaseTool : IAgentTool
}
}
- private bool IsWriteAttempt(string sql)
- {
- var lower = sql.ToLowerInvariant();
- return lower.Contains("insert") || lower.Contains("update") || lower.Contains("delete");
- }
-
- private bool IsAdminAttempt(string sql)
- {
- var lower = sql.ToLowerInvariant();
- return lower.Contains("drop") || lower.Contains("alter") || lower.Contains("create") || lower.Contains("truncate");
- }
-
private DatabaseAccessLevel GetAccessLevel(AgentToolContext context)
{
if (context.ToolConfig.TryGetValue("accessLevel", out var val) && val != null)
@@ -215,16 +203,24 @@ public sealed class DatabaseTool : IAgentTool
return DatabaseAccessLevel.ReadOnly;
}
- private bool IsTableAllowed(string input, AgentToolContext context)
+ internal static List GetAllowedTables(AgentToolContext context)
{
- if (!context.ToolConfig.TryGetValue("allowedTables", out var val) || val is not JsonElement je)
+ if (!context.ToolConfig.TryGetValue("allowedTables", out var val) || val is not JsonElement je
+ || je.ValueKind != JsonValueKind.Array)
{
- return false;
+ return [];
}
- var allowed = je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList();
- var inputLower = input.ToLowerInvariant();
-
- return allowed.Any(t => t != null && inputLower.Contains(t));
+ return je.EnumerateArray()
+ .Select(x => x.GetString())
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .ToList()!;
}
+
+ ///
+ /// MongoDB kennt kein SQL — hier genügt der exakte Vergleich des Collection-Namens.
+ /// Bisher wurde auch hier per Teilzeichenkette geprüft.
+ ///
+ private static bool IsCollectionAllowed(string collection, AgentToolContext context)
+ => GetAllowedTables(context).Contains(collection, StringComparer.OrdinalIgnoreCase);
}
diff --git a/src/ClawdDotNet.Tools.Database/SqlGuard.cs b/src/ClawdDotNet.Tools.Database/SqlGuard.cs
new file mode 100644
index 0000000..0300ef3
--- /dev/null
+++ b/src/ClawdDotNet.Tools.Database/SqlGuard.cs
@@ -0,0 +1,251 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace ClawdDotNet.Tools.Database;
+
+public enum SqlOperation
+{
+ Unknown,
+ Read, // SELECT, SHOW, DESCRIBE, EXPLAIN, WITH
+ Write, // INSERT, UPDATE, DELETE, MERGE, REPLACE
+ Schema // CREATE, DROP, ALTER, TRUNCATE, GRANT, …
+}
+
+public sealed record SqlInspection(
+ bool IsValid,
+ string? Error,
+ SqlOperation Operation,
+ IReadOnlyList Tables);
+
+///
+/// Prüft LLM-erzeugtes SQL, bevor es ausgeführt wird.
+///
+/// Die frühere Prüfung suchte den erlaubten Tabellennamen als Teilzeichenkette
+/// irgendwo im Statement — auch in einem Kommentar oder String-Literal. Bei
+/// allowedTables ["prices"] und ReadWrite genügte deshalb
+///
+/// DELETE FROM users -- prices
+///
+/// um eine beliebige andere Tabelle zu löschen. Ebenso wurde die Operation über
+/// Teilzeichenketten bestimmt, wodurch harmlose Abfragen wie
+/// SELECT … WHERE note='update' fälschlich als Schreibzugriff galten.
+///
+/// Diese Klasse geht anders vor:
+/// 1. Kommentare und String-Literale werden entfernt, bevor irgendetwas geprüft wird.
+/// 2. Mehrere Statements in einem Aufruf werden abgelehnt.
+/// 3. Die Operation ergibt sich aus dem ersten Schlüsselwort, nicht aus Vorkommen.
+/// 4. Tabellennamen werden gezielt hinter FROM/JOIN/INTO/UPDATE/… extrahiert;
+/// JEDER davon muss auf der Whitelist stehen.
+///
+public static class SqlGuard
+{
+ private static readonly HashSet ReadKeywords =
+ new(StringComparer.OrdinalIgnoreCase) { "SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH" };
+
+ private static readonly HashSet WriteKeywords =
+ new(StringComparer.OrdinalIgnoreCase) { "INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE", "UPSERT" };
+
+ private static readonly HashSet SchemaKeywords =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ "CREATE", "DROP", "ALTER", "TRUNCATE", "GRANT", "REVOKE",
+ "RENAME", "ATTACH", "DETACH", "PRAGMA", "SET", "CALL", "EXEC", "EXECUTE"
+ };
+
+ /// Schlüsselwörter, nach denen eine Tabellenliste folgt.
+ private static readonly Regex TableKeyword = new(
+ @"\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\b",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ /// Beendet eine Tabellenliste.
+ private static readonly Regex ListTerminator = new(
+ @"\b(?:WHERE|GROUP|ORDER|HAVING|LIMIT|ON|USING|SET|VALUES|UNION|EXCEPT|INTERSECT|" +
+ @"JOIN|INNER|LEFT|RIGHT|FULL|CROSS|OUTER|NATURAL|SELECT|RETURNING|WINDOW|FETCH|OFFSET|" +
+ @"ADD|DROP|RENAME|MODIFY|ALTER)\b",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ /// Erster Bezeichner eines Listeneintrags — ein evtl. folgender Alias wird ignoriert.
+ private static readonly Regex LeadingIdentifier = new(
+ @"^[\s`\[""]*(?[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?)",
+ RegexOptions.Compiled);
+
+ public static SqlInspection Inspect(string? sql, IReadOnlyCollection allowedTables)
+ {
+ if (string.IsNullOrWhiteSpace(sql))
+ return Invalid("Leeres SQL-Statement.");
+
+ // Kommentare sind für maschinell erzeugtes SQL ohne Nutzen, aber das ideale
+ // Versteck, um eine erlaubte Tabelle vorzutäuschen.
+ if (ContainsComment(sql))
+ return Invalid("SQL-Kommentare sind nicht erlaubt.");
+
+ var normalized = RemoveStringLiterals(sql);
+
+ if (HasMultipleStatements(normalized))
+ return Invalid("Mehrere Statements in einem Aufruf sind nicht erlaubt.");
+
+ var operation = DetermineOperation(normalized);
+ if (operation == SqlOperation.Unknown)
+ return Invalid("Statement-Art konnte nicht bestimmt werden.");
+
+ var tables = ExtractTables(normalized);
+ if (tables.Count == 0)
+ return new SqlInspection(false, "Keine Tabelle im Statement erkannt.", operation, tables);
+
+ // Fail closed: ohne Whitelist wird nichts durchgelassen.
+ if (allowedTables.Count == 0)
+ return new SqlInspection(false, "Keine Tabellen freigegeben (allowedTables fehlt).", operation, tables);
+
+ var allowed = new HashSet(allowedTables, StringComparer.OrdinalIgnoreCase);
+ var denied = tables.Where(t => !allowed.Contains(StripSchema(t))).ToList();
+
+ if (denied.Count > 0)
+ {
+ return new SqlInspection(false,
+ $"Zugriff auf nicht freigegebene Tabelle(n): {string.Join(", ", denied)}. " +
+ $"Erlaubt sind: {string.Join(", ", allowedTables)}.",
+ operation, tables);
+ }
+
+ return new SqlInspection(true, null, operation, tables);
+ }
+
+ // ─── Bausteine ───
+
+ private static SqlInspection Invalid(string error)
+ => new(false, error, SqlOperation.Unknown, []);
+
+ private static bool ContainsComment(string sql)
+ => sql.Contains("--", StringComparison.Ordinal)
+ || sql.Contains("/*", StringComparison.Ordinal)
+ || sql.Contains('#');
+
+ ///
+ /// Ersetzt den Inhalt von String-Literalen durch Leerraum, damit dort weder
+ /// Schlüsselwörter noch Tabellennamen erkannt werden.
+ ///
+ private static string RemoveStringLiterals(string sql)
+ {
+ var sb = new StringBuilder(sql.Length);
+ var inSingle = false;
+ var inDouble = false;
+
+ for (var i = 0; i < sql.Length; i++)
+ {
+ var c = sql[i];
+
+ if (inSingle)
+ {
+ if (c == '\'')
+ {
+ // Verdoppeltes Apostroph ist ein escaptes Zeichen, kein Ende.
+ if (i + 1 < sql.Length && sql[i + 1] == '\'') { i++; continue; }
+ inSingle = false;
+ sb.Append(' ');
+ }
+ continue;
+ }
+
+ if (inDouble)
+ {
+ if (c == '"')
+ {
+ if (i + 1 < sql.Length && sql[i + 1] == '"') { i++; continue; }
+ inDouble = false;
+ sb.Append(' ');
+ }
+ continue;
+ }
+
+ switch (c)
+ {
+ case '\'':
+ inSingle = true;
+ sb.Append(' ');
+ break;
+ case '"':
+ inDouble = true;
+ sb.Append(' ');
+ break;
+ default:
+ sb.Append(c);
+ break;
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private static bool HasMultipleStatements(string normalized)
+ {
+ var trimmed = normalized.TrimEnd();
+ var semicolon = trimmed.IndexOf(';');
+
+ // Ein abschließendes Semikolon ist in Ordnung, alles danach nicht.
+ return semicolon >= 0 && semicolon < trimmed.Length - 1;
+ }
+
+ private static SqlOperation DetermineOperation(string normalized)
+ {
+ var firstWord = normalized
+ .Split([' ', '\t', '\r', '\n', '(', ';'], StringSplitOptions.RemoveEmptyEntries)
+ .FirstOrDefault();
+
+ if (firstWord is null)
+ return SqlOperation.Unknown;
+
+ if (SchemaKeywords.Contains(firstWord)) return SqlOperation.Schema;
+ if (WriteKeywords.Contains(firstWord)) return SqlOperation.Write;
+ if (ReadKeywords.Contains(firstWord)) return SqlOperation.Read;
+
+ return SqlOperation.Unknown;
+ }
+
+ private static List ExtractTables(string normalized)
+ {
+ var tables = new List();
+
+ foreach (Match keyword in TableKeyword.Matches(normalized))
+ {
+ var rest = normalized[(keyword.Index + keyword.Length)..];
+ tables.AddRange(ParseTableList(rest));
+ }
+
+ return tables.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ ///
+ /// Liest die auf ein Schlüsselwort folgende Tabellenliste — auch mehrere durch Komma
+ /// getrennte Einträge samt Aliassen ("FROM prices p, users u").
+ ///
+ private static IEnumerable ParseTableList(string rest)
+ {
+ // Die Liste endet am nächsten Schlüsselwort oder an einer öffnenden Klammer
+ // (Spaltenliste bei INSERT, Unterabfrage bei FROM).
+ var end = rest.Length;
+
+ var terminator = ListTerminator.Match(rest);
+ if (terminator.Success)
+ end = Math.Min(end, terminator.Index);
+
+ var parenthesis = rest.IndexOf('(');
+ if (parenthesis >= 0)
+ end = Math.Min(end, parenthesis);
+
+ var segment = rest[..end];
+
+ foreach (var entry in segment.Split(','))
+ {
+ var match = LeadingIdentifier.Match(entry);
+ if (match.Success)
+ yield return match.Groups["table"].Value;
+ }
+ }
+
+ /// "schema.tabelle" → "tabelle" (die Whitelist listet Tabellennamen).
+ private static string StripSchema(string table)
+ {
+ var dot = table.LastIndexOf('.');
+ return dot >= 0 ? table[(dot + 1)..] : table;
+ }
+}
diff --git a/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs b/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs
index 3e5c73e..0c220a6 100644
--- a/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs
+++ b/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs
@@ -293,6 +293,9 @@ public sealed class DirectApiTool : IAgentTool
private static async Task<(JsonElement? json, ToolResult? error)> SafeGetJsonAsync(
HttpClient http, string url, CancellationToken ct)
{
+ // Auch Fehlermeldungen gehen an das Modell — der Schlüssel darf darin nicht auftauchen.
+ var safeUrl = UrlSanitizer.Sanitize(url);
+
HttpResponseMessage response;
try
{
@@ -300,7 +303,7 @@ public sealed class DirectApiTool : IAgentTool
}
catch (HttpRequestException ex)
{
- return (null, ToolResult.Fail($"API nicht erreichbar: {url} → {ex.Message}"));
+ return (null, ToolResult.Fail($"API nicht erreichbar: {safeUrl} → {ex.Message}"));
}
var body = await response.Content.ReadAsStringAsync(ct);
@@ -309,7 +312,7 @@ public sealed class DirectApiTool : IAgentTool
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
- $"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {url}\nAntwort: {preview}"));
+ $"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {safeUrl}\nAntwort: {preview}"));
}
// Prüfen ob die Antwort überhaupt JSON ist
@@ -318,7 +321,7 @@ public sealed class DirectApiTool : IAgentTool
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
- $"API hat kein JSON zurückgegeben ({url}). Antwort: {preview}"));
+ $"API hat kein JSON zurückgegeben ({safeUrl}). Antwort: {preview}"));
}
try
@@ -330,7 +333,7 @@ public sealed class DirectApiTool : IAgentTool
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
- $"Ungültiges JSON von {url}: {ex.Message}\nAntwort: {preview}"));
+ $"Ungültiges JSON von {safeUrl}: {ex.Message}\nAntwort: {preview}"));
}
}
@@ -348,7 +351,9 @@ public sealed class DirectApiTool : IAgentTool
{
fetchedAt = fetchedAt,
dataAsOf = dataAsOf,
- source = source,
+ // Ohne Bereinigung ginge der API-Schlüssel im Query-String an das Modell,
+ // in den persistierten Kontext und in die Logs.
+ source = UrlSanitizer.Sanitize(source),
data = data
};
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
diff --git a/src/ClawdDotNet.Tools.DirectAPI/UrlSanitizer.cs b/src/ClawdDotNet.Tools.DirectAPI/UrlSanitizer.cs
new file mode 100644
index 0000000..b981ee5
--- /dev/null
+++ b/src/ClawdDotNet.Tools.DirectAPI/UrlSanitizer.cs
@@ -0,0 +1,63 @@
+namespace ClawdDotNet.Tools.DirectAPI;
+
+///
+/// Entfernt Zugangsdaten aus URLs, bevor diese das Tool verlassen.
+///
+/// Hintergrund: Die abgerufene URL wurde als "source" an das Modell zurückgegeben —
+/// samt "apikey=" im Query-String. Damit landete der Schlüssel im Konversationskontext,
+/// wurde bei jedem Folgeschritt erneut an den Anbieter gesendet, in ChatContext.json
+/// auf die Platte geschrieben und in die Logs übernommen.
+///
+public static class UrlSanitizer
+{
+ private static readonly string[] SensitiveParameters =
+ [
+ "apikey", "api_key", "key", "token", "access_token", "apitoken",
+ "secret", "password", "auth", "signature", "sig"
+ ];
+
+ private const string Mask = "***";
+
+ ///
+ /// Ersetzt die Werte sicherheitsrelevanter Query-Parameter durch eine Maske.
+ /// Der Rest der URL bleibt lesbar, damit die Herkunft der Daten nachvollziehbar ist.
+ ///
+ public static string Sanitize(string? url)
+ {
+ if (string.IsNullOrWhiteSpace(url))
+ return url ?? "";
+
+ var queryStart = url.IndexOf('?');
+ if (queryStart < 0)
+ return url;
+
+ var baseUrl = url[..queryStart];
+ var query = url[(queryStart + 1)..];
+
+ // Fragment abtrennen, damit es hinten wieder angehängt werden kann.
+ var fragment = "";
+ var fragmentStart = query.IndexOf('#');
+ if (fragmentStart >= 0)
+ {
+ fragment = query[fragmentStart..];
+ query = query[..fragmentStart];
+ }
+
+ var parts = query.Split('&');
+ for (var i = 0; i < parts.Length; i++)
+ {
+ var separator = parts[i].IndexOf('=');
+ if (separator < 0)
+ continue;
+
+ var name = parts[i][..separator];
+ if (IsSensitive(name))
+ parts[i] = name + "=" + Mask;
+ }
+
+ return baseUrl + "?" + string.Join("&", parts) + fragment;
+ }
+
+ private static bool IsSensitive(string parameterName)
+ => SensitiveParameters.Contains(parameterName.Trim().ToLowerInvariant());
+}
diff --git a/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs b/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs
index c59a37d..497e908 100644
--- a/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs
+++ b/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs
@@ -415,36 +415,24 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
// --- Helper ---
- ///
- /// Löst Channel-Angaben auf: Handle (@Name / Name), Channel-URL, oder volle URL.
- ///
- private static string ResolveChannelUrl(string channelInput)
- {
- var input = channelInput.Trim();
-
- // Bereits eine URL → direkt nutzen
- if (input.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
- input.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
- return input;
-
- // Handle ohne @ → hinzufügen
- if (!input.StartsWith('@'))
- input = "@" + input;
-
- return $"https://www.youtube.com/{input}/videos";
- }
+ // Channel-Auflösung liegt jetzt in YouTubeUrl.TryResolveChannelUrl — dort wird die
+ // Eingabe validiert, bevor sie an einen externen Prozess geht.
private async Task GetLatestVideoIdAsync(string channelInput, string ytDlpPath, CancellationToken ct)
{
try
{
- var channelUrl = ResolveChannelUrl(channelInput);
- var psi = new ProcessStartInfo(ytDlpPath, $"--print \"%(id)s\" --playlist-end 1 {channelUrl}")
+ if (!YouTubeUrl.TryResolveChannelUrl(channelInput, out var channelUrl, out _))
+ return null;
+
+ var psi = new ProcessStartInfo(ytDlpPath)
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
+ foreach (var arg in YouTubeUrl.BuildLatestVideoIdArgs(channelUrl))
+ psi.ArgumentList.Add(arg);
using var process = Process.Start(psi);
if (process == null) return null;
@@ -464,30 +452,39 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
{
try
{
+ // Eingabe validieren, bevor irgendetwas an einen externen Prozess geht.
+ if (!YouTubeUrl.TryResolveChannelUrl(url, out var safeUrl, out var urlError))
+ throw new ArgumentException($"Ungültige YouTube-Adresse: {urlError}");
+
var targetDir = Path.Combine(workspacePath, "YTTranscript");
var tmpDir = Path.Combine(targetDir, "tmp");
if (!Directory.Exists(tmpDir)) Directory.CreateDirectory(tmpDir);
// 1. Video-ID ermitteln
- var videoId = await GetLatestVideoIdAsync(url, ytDlpPath, ct);
+ var videoId = await GetLatestVideoIdAsync(safeUrl, ytDlpPath, ct);
if (string.IsNullOrWhiteSpace(videoId)) videoId = Guid.NewGuid().ToString();
// 2. Audio herunterladen
string audioFile;
+ var ffmpegDir = !string.IsNullOrEmpty(ffmpegPath) && ffmpegPath != "ffmpeg"
+ ? Path.GetDirectoryName(ffmpegPath)
+ : null;
+
if (hasFfmpeg)
{
// Mit ffmpeg: yt-dlp konvertiert direkt zu mp3
audioFile = Path.Combine(tmpDir, $"{videoId}.mp3");
- var ffmpegLocArg = !string.IsNullOrEmpty(ffmpegPath) && ffmpegPath != "ffmpeg"
- ? $"--ffmpeg-location \"{Path.GetDirectoryName(ffmpegPath)}\" "
- : "";
- await RunYtDlpAsync(ytDlpPath, $"{ffmpegLocArg}-x --audio-format mp3 -o \"{audioFile}\" {url}", logger, ct);
+ await RunYtDlpAsync(ytDlpPath,
+ YouTubeUrl.BuildAudioDownloadArgs(audioFile, safeUrl, ffmpegDir, convertToMp3: true),
+ logger, ct);
}
else
{
// Ohne ffmpeg: Audio im Originalformat herunterladen
var outputTemplate = Path.Combine(tmpDir, $"{videoId}.%(ext)s");
- await RunYtDlpAsync(ytDlpPath, $"-x -o \"{outputTemplate}\" {url}", logger, ct);
+ await RunYtDlpAsync(ytDlpPath,
+ YouTubeUrl.BuildAudioDownloadArgs(outputTemplate, safeUrl, null, convertToMp3: false),
+ logger, ct);
audioFile = ""; // wird unten gesucht
}
@@ -540,16 +537,22 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
}
}
- private async Task RunYtDlpAsync(string ytDlpPath, string arguments, ILogger logger, CancellationToken ct)
+ ///
+ /// Startet yt-dlp. Die Argumente werden einzeln übergeben (ArgumentList), damit
+ /// kein Wert versehentlich als weitere Option interpretiert werden kann.
+ ///
+ private async Task RunYtDlpAsync(string ytDlpPath, List arguments, ILogger logger, CancellationToken ct)
{
try
{
- var psi = new ProcessStartInfo(ytDlpPath, arguments)
+ var psi = new ProcessStartInfo(ytDlpPath)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true
};
+ foreach (var arg in arguments)
+ psi.ArgumentList.Add(arg);
using var process = Process.Start(psi);
if (process != null)
@@ -596,13 +599,24 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
// ffmpeg: 20-Minuten-Segmente als mp3 (unter 25 MB Whisper-Limit bei q:a 5)
var chunkPattern = Path.Combine(chunkDir, "chunk_%03d.mp3");
var ffmpegExe = !string.IsNullOrEmpty(ffmpegPath) ? ffmpegPath : "ffmpeg";
- var psi = new ProcessStartInfo(ffmpegExe,
- $"-i \"{filePath}\" -f segment -segment_time 1200 -c:a libmp3lame -q:a 5 \"{chunkPattern}\"")
+ var psi = new ProcessStartInfo(ffmpegExe)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true
};
+ foreach (var arg in new[]
+ {
+ "-i", filePath,
+ "-f", "segment",
+ "-segment_time", "1200",
+ "-c:a", "libmp3lame",
+ "-q:a", "5",
+ chunkPattern
+ })
+ {
+ psi.ArgumentList.Add(arg);
+ }
using var process = Process.Start(psi);
if (process is not null)
diff --git a/src/ClawdDotNet.Tools.SocialMediaManager/YouTubeUrl.cs b/src/ClawdDotNet.Tools.SocialMediaManager/YouTubeUrl.cs
new file mode 100644
index 0000000..ac4d7c1
--- /dev/null
+++ b/src/ClawdDotNet.Tools.SocialMediaManager/YouTubeUrl.cs
@@ -0,0 +1,150 @@
+using System.Text.RegularExpressions;
+
+namespace ClawdDotNet.Tools.SocialMediaManager;
+
+///
+/// Prüft und normalisiert YouTube-Adressen, bevor sie an yt-dlp übergeben werden.
+///
+/// Hintergrund (S2): Die Eingabe wurde ungeprüft in eine Argument-Zeichenkette
+/// interpoliert. UseShellExecute = false verhindert zwar Shell-Metazeichen,
+/// nicht aber Options-Injection: yt-dlp kennt --exec, das beliebige Befehle nach
+/// dem Download ausführt. Ein Wert wie
+/// --exec "cmd /c …" https://youtube.com/… führte damit zu Codeausführung.
+///
+/// Kritisch ist das, weil der Agent untrusted Inhalte verarbeitet (Videotitel, Posts,
+/// Mails) — eine Prompt-Injection darin kann ihn dazu bringen, genau so einen Wert zu
+/// setzen.
+///
+public static class YouTubeUrl
+{
+ private static readonly string[] AllowedHosts =
+ [
+ "youtube.com", "www.youtube.com", "m.youtube.com",
+ "music.youtube.com", "youtu.be", "www.youtu.be"
+ ];
+
+ /// Handles wie "@ChannelName" — bewusst eng gefasst.
+ private static readonly Regex HandlePattern = new(
+ @"^@?[A-Za-z0-9._-]{1,100}$", RegexOptions.Compiled);
+
+ ///
+ /// Löst eine Channel-Angabe zu einer vollständigen URL auf.
+ /// Akzeptiert ein Handle ("@Name" oder "Name") oder eine YouTube-URL.
+ ///
+ public static bool TryResolveChannelUrl(string? input, out string url, out string? error)
+ {
+ url = "";
+ error = null;
+
+ var value = input?.Trim() ?? "";
+
+ if (value.Length == 0)
+ {
+ error = "Kanal-Angabe fehlt.";
+ return false;
+ }
+
+ // Alles, was wie eine Option aussieht, sofort ablehnen — auch wenn die
+ // Argumentübergabe inzwischen sauber quotet.
+ if (value.StartsWith('-'))
+ {
+ error = "Kanal-Angabe darf nicht mit '-' beginnen.";
+ return false;
+ }
+
+ if (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
+ value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ if (!TryValidateUrl(value, out error))
+ return false;
+
+ url = value;
+ return true;
+ }
+
+ if (!HandlePattern.IsMatch(value))
+ {
+ error = "Ungültiges Kanal-Handle. Erlaubt sind Buchstaben, Ziffern, Punkt, " +
+ "Bindestrich und Unterstrich.";
+ return false;
+ }
+
+ var handle = value.StartsWith('@') ? value : "@" + value;
+ url = $"https://www.youtube.com/{handle}/videos";
+ return true;
+ }
+
+ /// Prüft eine Video- oder Kanal-URL gegen die erlaubten Hosts.
+ public static bool TryValidateUrl(string? input, out string? error)
+ {
+ error = null;
+ var value = input?.Trim() ?? "";
+
+ if (value.StartsWith('-'))
+ {
+ error = "URL darf nicht mit '-' beginnen.";
+ return false;
+ }
+
+ if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
+ {
+ error = "Keine gültige URL.";
+ return false;
+ }
+
+ if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
+ {
+ error = $"Nicht unterstütztes Schema '{uri.Scheme}'. Erlaubt sind http und https.";
+ return false;
+ }
+
+ if (!AllowedHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase))
+ {
+ error = $"Host '{uri.Host}' ist nicht erlaubt. Zulässig sind nur YouTube-Adressen.";
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Baut die Argumentliste, um die neueste Video-Id eines Kanals abzufragen.
+ /// Die Liste wird an ProcessStartInfo.ArgumentList übergeben, wo jedes
+ /// Element als genau ein Argument ankommt.
+ ///
+ public static List BuildLatestVideoIdArgs(string channelUrl) =>
+ [
+ "--print", "%(id)s",
+ "--playlist-end", "1",
+ "--", // beendet die Optionsliste
+ channelUrl
+ ];
+
+ /// Baut die Argumentliste für den Audio-Download.
+ public static List BuildAudioDownloadArgs(
+ string outputPath, string videoUrl, string? ffmpegDirectory, bool convertToMp3)
+ {
+ var args = new List();
+
+ if (!string.IsNullOrEmpty(ffmpegDirectory))
+ {
+ args.Add("--ffmpeg-location");
+ args.Add(ffmpegDirectory);
+ }
+
+ args.Add("-x");
+
+ if (convertToMp3)
+ {
+ args.Add("--audio-format");
+ args.Add("mp3");
+ }
+
+ args.Add("-o");
+ args.Add(outputPath);
+ args.Add("--");
+ args.Add(videoUrl);
+
+ return args;
+ }
+}
diff --git a/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj b/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj
new file mode 100644
index 0000000..03fb159
--- /dev/null
+++ b/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ ClawdDotNet.Tools.Tests
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ClawdDotNet.Tools.Tests/Database/SqlGuardTests.cs b/tests/ClawdDotNet.Tools.Tests/Database/SqlGuardTests.cs
new file mode 100644
index 0000000..683451a
--- /dev/null
+++ b/tests/ClawdDotNet.Tools.Tests/Database/SqlGuardTests.cs
@@ -0,0 +1,158 @@
+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);
+ }
+}
diff --git a/tests/ClawdDotNet.Tools.Tests/DirectAPI/UrlSanitizerTests.cs b/tests/ClawdDotNet.Tools.Tests/DirectAPI/UrlSanitizerTests.cs
new file mode 100644
index 0000000..3338ea4
--- /dev/null
+++ b/tests/ClawdDotNet.Tools.Tests/DirectAPI/UrlSanitizerTests.cs
@@ -0,0 +1,95 @@
+using ClawdDotNet.Tools.DirectAPI;
+using Shouldly;
+
+namespace ClawdDotNet.Tools.Tests.DirectAPI;
+
+///
+/// S3 aus der Bestandsaufnahme: Die abgerufene URL ging als "source" an das Modell —
+/// inklusive apikey= im Query-String. Der Schlüssel landete damit im Kontext, wurde
+/// bei jedem Folgeschritt erneut gesendet und in ChatContext.json geschrieben.
+///
+public sealed class UrlSanitizerTests
+{
+ private const string Secret = "GEHEIM-abc123XYZ";
+
+ [Theory]
+ [InlineData("https://api.twelvedata.com/quote?symbol=NVDA&apikey=GEHEIM-abc123XYZ")]
+ [InlineData("https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=X&apikey=GEHEIM-abc123XYZ")]
+ [InlineData("https://api.example.com/v1?api_key=GEHEIM-abc123XYZ&x=1")]
+ [InlineData("https://api.example.com/v1?token=GEHEIM-abc123XYZ")]
+ [InlineData("https://api.example.com/v1?access_token=GEHEIM-abc123XYZ")]
+ [InlineData("https://api.example.com/v1?KEY=GEHEIM-abc123XYZ")]
+ [InlineData("https://api.example.com/v1?ApiKey=GEHEIM-abc123XYZ&other=2")]
+ [InlineData("https://api.example.com/v1?secret=GEHEIM-abc123XYZ")]
+ [InlineData("https://api.example.com/v1?a=1&apikey=GEHEIM-abc123XYZ#fragment")]
+ public void Sensible_Parameter_werden_maskiert(string url)
+ {
+ var sanitized = UrlSanitizer.Sanitize(url);
+
+ sanitized.ShouldNotContain(Secret);
+ sanitized.ShouldContain("***");
+ }
+
+ [Fact]
+ public void Harmlose_Parameter_bleiben_lesbar()
+ {
+ var sanitized = UrlSanitizer.Sanitize(
+ "https://api.twelvedata.com/quote?symbol=NVDA&interval=1day&apikey=GEHEIM-abc123XYZ");
+
+ sanitized.ShouldContain("symbol=NVDA");
+ sanitized.ShouldContain("interval=1day");
+ sanitized.ShouldContain("apikey=***");
+ }
+
+ [Fact]
+ public void Der_Pfad_bleibt_erhalten()
+ {
+ var sanitized = UrlSanitizer.Sanitize("https://api.massive.com/v2/aggs/ticker/NVDA?apikey=x");
+
+ sanitized.ShouldStartWith("https://api.massive.com/v2/aggs/ticker/NVDA?");
+ }
+
+ [Fact]
+ public void Ein_Fragment_bleibt_erhalten()
+ {
+ var sanitized = UrlSanitizer.Sanitize("https://x.de/a?apikey=geheim#abschnitt");
+
+ sanitized.ShouldEndWith("#abschnitt");
+ sanitized.ShouldNotContain("geheim");
+ }
+
+ [Theory]
+ [InlineData("https://api.example.com/v1")]
+ [InlineData("https://api.example.com/v1?symbol=NVDA")]
+ public void URLs_ohne_sensible_Parameter_bleiben_unveraendert(string url)
+ {
+ UrlSanitizer.Sanitize(url).ShouldBe(url);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Leere_Eingaben_fuehren_nicht_zu_einem_Fehler(string? url)
+ {
+ Should.NotThrow(() => UrlSanitizer.Sanitize(url));
+ }
+
+ [Fact]
+ public void Ein_Parameter_ohne_Wert_stoert_nicht()
+ {
+ var sanitized = UrlSanitizer.Sanitize("https://x.de/a?flag&apikey=geheim");
+
+ sanitized.ShouldContain("flag");
+ sanitized.ShouldNotContain("geheim");
+ }
+
+ [Fact]
+ public void Ein_Schluessel_als_Teil_eines_anderen_Namens_wird_nicht_faelschlich_maskiert()
+ {
+ // "monkey" enthält "key", ist aber kein sensibler Parameter.
+ var sanitized = UrlSanitizer.Sanitize("https://x.de/a?monkey=banane");
+
+ sanitized.ShouldBe("https://x.de/a?monkey=banane");
+ }
+}
diff --git a/tests/ClawdDotNet.Tools.Tests/GlobalUsings.cs b/tests/ClawdDotNet.Tools.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/tests/ClawdDotNet.Tools.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/ClawdDotNet.Tools.Tests/SocialMedia/YouTubeUrlTests.cs b/tests/ClawdDotNet.Tools.Tests/SocialMedia/YouTubeUrlTests.cs
new file mode 100644
index 0000000..a3ca8b4
--- /dev/null
+++ b/tests/ClawdDotNet.Tools.Tests/SocialMedia/YouTubeUrlTests.cs
@@ -0,0 +1,139 @@
+using ClawdDotNet.Tools.SocialMediaManager;
+using Shouldly;
+
+namespace ClawdDotNet.Tools.Tests.SocialMedia;
+
+///
+/// S2 aus der Bestandsaufnahme: Die Kanal-/Video-Angabe wurde ungeprüft in eine
+/// Argument-Zeichenkette für yt-dlp interpoliert. UseShellExecute=false verhindert
+/// Shell-Metazeichen, nicht aber Options-Injection — yt-dlp kennt --exec, das beliebige
+/// Befehle ausführt.
+///
+/// Die Angriffsfälle bleiben hier dauerhaft als Testfälle dokumentiert.
+///
+public sealed class YouTubeUrlTests
+{
+ // ═══════════════════════════════════════════════════════════
+ // Angriffe
+ // ═══════════════════════════════════════════════════════════
+
+ [Theory]
+ [InlineData("--exec cmd.exe https://www.youtube.com/@kanal")]
+ [InlineData("--exec=calc.exe")]
+ [InlineData("-o /tmp/evil")]
+ [InlineData("--config-location /tmp/evil.conf")]
+ [InlineData("--paths /windows/system32")]
+ [InlineData("-")]
+ public void Optionsartige_Eingaben_werden_abgelehnt(string input)
+ {
+ YouTubeUrl.TryResolveChannelUrl(input, out _, out var error).ShouldBeFalse();
+ error.ShouldNotBeNull();
+ }
+
+ [Theory]
+ [InlineData("https://evil.com/video")]
+ [InlineData("https://youtube.com.attacker.net/@kanal")]
+ [InlineData("https://notyoutube.com/@kanal")]
+ [InlineData("http://192.168.178.10:8418/Richard/ClawdDotNet.git")]
+ [InlineData("file:///C:/Windows/win.ini")]
+ [InlineData("ftp://example.com/datei")]
+ public void Fremde_Hosts_und_Schemata_werden_abgelehnt(string url)
+ {
+ YouTubeUrl.TryResolveChannelUrl(url, out _, out var error).ShouldBeFalse();
+ error.ShouldNotBeNull();
+ }
+
+ [Theory]
+ [InlineData("kanal name")] // Leerzeichen
+ [InlineData("kanal\"name")] // Anführungszeichen
+ [InlineData("kanal\nname")] // Zeilenumbruch
+ [InlineData("kanal;name")]
+ [InlineData("kanal&name")]
+ [InlineData("../../etc/passwd")]
+ [InlineData("@kanal/../../evil")]
+ public void Unsaubere_Handles_werden_abgelehnt(string input)
+ {
+ YouTubeUrl.TryResolveChannelUrl(input, out _, out var error).ShouldBeFalse();
+ error.ShouldNotBeNull();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Leere_Eingaben_werden_abgelehnt(string? input)
+ {
+ YouTubeUrl.TryResolveChannelUrl(input, out _, out _).ShouldBeFalse();
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // Gegenproben — legitime Eingaben müssen weiter funktionieren
+ // ═══════════════════════════════════════════════════════════
+
+ [Theory]
+ [InlineData("@Computerphile", "https://www.youtube.com/@Computerphile/videos")]
+ [InlineData("Computerphile", "https://www.youtube.com/@Computerphile/videos")]
+ [InlineData("kanal_mit-punkt.name", "https://www.youtube.com/@kanal_mit-punkt.name/videos")]
+ public void Handles_werden_zu_Kanal_URLs(string input, string expected)
+ {
+ YouTubeUrl.TryResolveChannelUrl(input, out var url, out var error).ShouldBeTrue(error);
+ url.ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData("https://www.youtube.com/watch?v=dQw4w9WgXcQ")]
+ [InlineData("https://youtube.com/@kanal/videos")]
+ [InlineData("https://m.youtube.com/watch?v=abc")]
+ [InlineData("https://music.youtube.com/watch?v=abc")]
+ [InlineData("https://youtu.be/dQw4w9WgXcQ")]
+ public void Echte_YouTube_URLs_werden_durchgelassen(string url)
+ {
+ YouTubeUrl.TryResolveChannelUrl(url, out var resolved, out var error).ShouldBeTrue(error);
+ resolved.ShouldBe(url);
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // Argumentbildung
+ // ═══════════════════════════════════════════════════════════
+
+ [Fact]
+ public void Die_URL_steht_hinter_dem_Optionsende_Trenner()
+ {
+ // "--" beendet die Optionsliste. Selbst wenn ein Wert wie eine Option aussähe,
+ // würde yt-dlp ihn danach als Adresse behandeln.
+ var args = YouTubeUrl.BuildLatestVideoIdArgs("https://www.youtube.com/@kanal/videos");
+
+ var separator = args.IndexOf("--");
+ separator.ShouldBeGreaterThanOrEqualTo(0);
+ args[^1].ShouldBe("https://www.youtube.com/@kanal/videos");
+ args.IndexOf(args[^1]).ShouldBeGreaterThan(separator);
+ }
+
+ [Fact]
+ public void Jedes_Argument_ist_ein_eigener_Eintrag()
+ {
+ // Entscheidend: Kein Eintrag darf mehrere Argumente enthalten — sonst könnten
+ // sie beim Start wieder aufgetrennt werden.
+ var args = YouTubeUrl.BuildAudioDownloadArgs(
+ @"C:\Pfad mit Leerzeichen\datei.mp3",
+ "https://www.youtube.com/watch?v=abc",
+ @"C:\ffmpeg\bin",
+ convertToMp3: true);
+
+ args.ShouldContain(@"C:\Pfad mit Leerzeichen\datei.mp3");
+ args.ShouldContain(@"C:\ffmpeg\bin");
+ args.ShouldContain("--audio-format");
+ args.ShouldContain("mp3");
+ args[^1].ShouldBe("https://www.youtube.com/watch?v=abc");
+ }
+
+ [Fact]
+ public void Ohne_ffmpeg_entfaellt_die_Konvertierung()
+ {
+ var args = YouTubeUrl.BuildAudioDownloadArgs(
+ "/tmp/x.%(ext)s", "https://youtu.be/abc", null, convertToMp3: false);
+
+ args.ShouldNotContain("--audio-format");
+ args.ShouldNotContain("--ffmpeg-location");
+ }
+}