Sicherheitsluecken S1, S2 und S3 schliessen
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
69b5704add
commit
e5067cae70
@@ -415,36 +415,24 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
/// <summary>
|
||||
/// Löst Channel-Angaben auf: Handle (@Name / Name), Channel-URL, oder volle URL.
|
||||
/// </summary>
|
||||
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<string?> 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)
|
||||
/// <summary>
|
||||
/// Startet yt-dlp. Die Argumente werden einzeln übergeben (ArgumentList), damit
|
||||
/// kein Wert versehentlich als weitere Option interpretiert werden kann.
|
||||
/// </summary>
|
||||
private async Task RunYtDlpAsync(string ytDlpPath, List<string> 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)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClawdDotNet.Tools.SocialMediaManager;
|
||||
|
||||
/// <summary>
|
||||
/// 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. <c>UseShellExecute = false</c> verhindert zwar Shell-Metazeichen,
|
||||
/// nicht aber Options-Injection: yt-dlp kennt <c>--exec</c>, das beliebige Befehle nach
|
||||
/// dem Download ausführt. Ein Wert wie
|
||||
/// <c>--exec "cmd /c …" https://youtube.com/…</c> 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.
|
||||
/// </summary>
|
||||
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"
|
||||
];
|
||||
|
||||
/// <summary>Handles wie "@ChannelName" — bewusst eng gefasst.</summary>
|
||||
private static readonly Regex HandlePattern = new(
|
||||
@"^@?[A-Za-z0-9._-]{1,100}$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Löst eine Channel-Angabe zu einer vollständigen URL auf.
|
||||
/// Akzeptiert ein Handle ("@Name" oder "Name") oder eine YouTube-URL.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Prüft eine Video- oder Kanal-URL gegen die erlaubten Hosts.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Argumentliste, um die neueste Video-Id eines Kanals abzufragen.
|
||||
/// Die Liste wird an <c>ProcessStartInfo.ArgumentList</c> übergeben, wo jedes
|
||||
/// Element als genau ein Argument ankommt.
|
||||
/// </summary>
|
||||
public static List<string> BuildLatestVideoIdArgs(string channelUrl) =>
|
||||
[
|
||||
"--print", "%(id)s",
|
||||
"--playlist-end", "1",
|
||||
"--", // beendet die Optionsliste
|
||||
channelUrl
|
||||
];
|
||||
|
||||
/// <summary>Baut die Argumentliste für den Audio-Download.</summary>
|
||||
public static List<string> BuildAudioDownloadArgs(
|
||||
string outputPath, string videoUrl, string? ffmpegDirectory, bool convertToMp3)
|
||||
{
|
||||
var args = new List<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user