diff --git a/docs/Bestandsaufnahme-2026-07.md b/docs/Bestandsaufnahme-2026-07.md index 6347135..d115ba0 100644 --- a/docs/Bestandsaufnahme-2026-07.md +++ b/docs/Bestandsaufnahme-2026-07.md @@ -538,7 +538,7 @@ Siehe K3. 9. ~~B4 Kostenerfassung korrigieren~~ ✅ teilweise: Prompt/Completion werden jetzt getrennt erfasst statt 50/50 geschätzt. Offen bleibt die veraltete, hartcodierte Preistabelle (`ModelPricing`) — Preise sollten vom `/models`-Endpoint kommen. -10. B12 Retry/Backoff +10. ~~B12 Retry/Backoff~~ ✅ umgesetzt 11. T4 Proaktiv statt reaktiv kompaktieren **Mittelfristig — Fundament** @@ -549,4 +549,5 @@ Siehe K3. 15. S4 PermissionGate ausbauen **Danach** -16. Restliche Bugs (B6–B10, B13), S5–S7, weitere Tools, Streaming +16. Restliche Bugs (B6–B10, B13), S4/S7, weitere Tools, Streaming + (~~S5~~ ✅ SSRF, ~~S6~~ ✅ Pfadprüfung sind behoben) diff --git a/src/ClawdDotNet.Core/Api/OpenRouterClient.cs b/src/ClawdDotNet.Core/Api/OpenRouterClient.cs index 2ff682d..1553144 100644 --- a/src/ClawdDotNet.Core/Api/OpenRouterClient.cs +++ b/src/ClawdDotNet.Core/Api/OpenRouterClient.cs @@ -12,15 +12,26 @@ public sealed class OpenRouterClient : IChatCompletionClient, IDisposable private readonly HttpClient _http; private readonly ILogger _logger; + private readonly RetryPolicy _retry; + + /// Wartefunktion — in Tests ersetzbar, damit nicht wirklich gewartet wird. + private readonly Func _delay; private static readonly JsonSerializerOptions JsonOptions = new() { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; - public OpenRouterClient(string apiKey, ILogger logger, HttpClient? httpClient = null) + public OpenRouterClient( + string apiKey, + ILogger logger, + HttpClient? httpClient = null, + RetryPolicy? retryPolicy = null, + Func? delay = null) { _logger = logger; + _retry = retryPolicy ?? RetryPolicy.Default; + _delay = delay ?? ((d, ct) => Task.Delay(d, ct)); _http = httpClient ?? new HttpClient(); _http.BaseAddress = new Uri(BaseUrl); _http.Timeout = TimeSpan.FromMinutes(5); // LLM-Calls können bei großen Prompts lange dauern @@ -34,21 +45,18 @@ public sealed class OpenRouterClient : IChatCompletionClient, IDisposable request.Stream = false; var json = JsonSerializer.Serialize(request, JsonOptions); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); _logger.LogDebug("Sending request to OpenRouter: model={Model}, messages={Count}", request.Model, request.Messages.Count); - using var response = await _http.PostAsync("chat/completions", content, ct); - var responseBody = await response.Content.ReadAsStringAsync(ct); + var (statusCode, responseBody) = await SendWithRetryAsync(json, ct); - if (!response.IsSuccessStatusCode) + if (statusCode is < 200 or > 299) { - _logger.LogError("OpenRouter API error {StatusCode}: {Body}", - (int)response.StatusCode, responseBody); + _logger.LogError("OpenRouter API error {StatusCode}: {Body}", statusCode, responseBody); throw new OpenRouterException( - $"API request failed with status {(int)response.StatusCode}", - (int)response.StatusCode, + $"API request failed with status {statusCode}", + statusCode, responseBody); } @@ -67,6 +75,69 @@ public sealed class OpenRouterClient : IChatCompletionClient, IDisposable return result; } + /// + /// Sendet die Anfrage und wiederholt sie bei vorübergehenden Störungen. + /// + /// Ohne das beendet ein einzelnes HTTP 429 einen kompletten geplanten Lauf — + /// Rate-Limits und kurzzeitige 5xx sind bei OpenRouter Normalbetrieb. + /// + private async Task<(int StatusCode, string Body)> SendWithRetryAsync(string json, CancellationToken ct) + { + Exception? lastNetworkError = null; + + for (var attempt = 1; attempt <= _retry.MaxAttempts; attempt++) + { + ct.ThrowIfCancellationRequested(); + + // Content muss je Versuch neu erzeugt werden — ein bereits gesendeter + // HttpContent lässt sich nicht erneut verwenden. + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + + try + { + using var response = await _http.PostAsync("chat/completions", content, ct); + var body = await response.Content.ReadAsStringAsync(ct); + var status = (int)response.StatusCode; + + if (response.IsSuccessStatusCode || !_retry.ShouldRetry(response.StatusCode)) + return (status, body); + + if (attempt == _retry.MaxAttempts) + { + _logger.LogWarning( + "OpenRouter antwortete {Status} — letzter von {Max} Versuchen aufgebraucht.", + status, _retry.MaxAttempts); + return (status, body); + } + + var wait = _retry.GetDelay(attempt, response.Headers.RetryAfter?.Delta); + _logger.LogWarning( + "OpenRouter antwortete {Status}. Versuch {Attempt}/{Max}, erneut in {Delay:N1}s.", + status, attempt, _retry.MaxAttempts, wait.TotalSeconds); + + await _delay(wait, ct); + } + catch (HttpRequestException ex) + { + lastNetworkError = ex; + + if (attempt == _retry.MaxAttempts) + break; + + var wait = _retry.GetDelay(attempt); + _logger.LogWarning(ex, + "Netzwerkfehler beim Aufruf von OpenRouter. Versuch {Attempt}/{Max}, erneut in {Delay:N1}s.", + attempt, _retry.MaxAttempts, wait.TotalSeconds); + + await _delay(wait, ct); + } + } + + throw new OpenRouterException( + $"OpenRouter nach {_retry.MaxAttempts} Versuchen nicht erreichbar: {lastNetworkError?.Message}", + 0, ""); + } + /// /// Ruft die verfügbaren Modelle von OpenRouter ab (/models Endpoint). /// Gibt eine Liste von Modell-IDs zurück, sortiert nach Name. diff --git a/src/ClawdDotNet.Core/Api/RetryPolicy.cs b/src/ClawdDotNet.Core/Api/RetryPolicy.cs new file mode 100644 index 0000000..d64ee53 --- /dev/null +++ b/src/ClawdDotNet.Core/Api/RetryPolicy.cs @@ -0,0 +1,71 @@ +using System.Net; + +namespace ClawdDotNet.Core.Api; + +/// +/// Entscheidet, ob ein fehlgeschlagener API-Aufruf wiederholt wird und wie lange +/// vorher gewartet wird. +/// +/// Hintergrund (B12): Der Client warf bei jedem Nicht-2xx sofort. Ein einzelnes +/// HTTP 429 beendete damit einen kompletten geplanten Lauf — bei OpenRouter sind +/// 429 und 5xx im Normalbetrieb aber zu erwarten. +/// +/// Die Logik ist bewusst frei von Wartezeiten und Zufall an der Aufrufstelle, damit +/// sie ohne echtes Warten testbar bleibt. +/// +public sealed class RetryPolicy +{ + /// Gesamtzahl der Versuche, inklusive des ersten. + public int MaxAttempts { get; init; } = 4; + + public TimeSpan BaseDelay { get; init; } = TimeSpan.FromSeconds(1); + + public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(30); + + /// Anteil zufälliger Streuung, damit parallele Agenten nicht im Gleichtakt erneut anfragen. + public double JitterFactor { get; init; } = 0.25; + + public static RetryPolicy Default { get; } = new(); + + /// Kein Wiederholen — für Tests und Sonderfälle. + public static RetryPolicy None { get; } = new() { MaxAttempts = 1 }; + + public bool ShouldRetry(HttpStatusCode status) => (int)status switch + { + 408 => true, // Request Timeout + 409 => false, + 425 => true, // Too Early + 429 => true, // Rate Limit — der häufigste Fall + >= 500 and <= 599 => true, // Serverseitige Störungen + _ => false + }; + + /// + /// Wartezeit vor dem nächsten Versuch. ist 1-basiert: + /// 1 bedeutet, dass der erste Versuch fehlgeschlagen ist. + /// + /// Ein vom Server gesendetes Retry-After hat Vorrang — es ist die verlässlichste + /// Angabe und zu ignorieren führt nur zu weiteren Ablehnungen. + /// + public TimeSpan GetDelay(int attempt, TimeSpan? retryAfter = null, double? jitterSample = null) + { + if (retryAfter is { } serverHint && serverHint > TimeSpan.Zero) + return serverHint > MaxDelay ? MaxDelay : serverHint; + + // Exponentiell: 1s, 2s, 4s, 8s … + var exponent = Math.Max(0, attempt - 1); + var scaled = BaseDelay.TotalMilliseconds * Math.Pow(2, exponent); + + if (scaled > MaxDelay.TotalMilliseconds) + scaled = MaxDelay.TotalMilliseconds; + + // Streuung im Bereich ±JitterFactor + var sample = jitterSample ?? Random.Shared.NextDouble(); + var jitter = 1.0 + (sample * 2 - 1) * JitterFactor; + + var result = scaled * jitter; + if (result < 0) result = 0; + + return TimeSpan.FromMilliseconds(Math.Min(result, MaxDelay.TotalMilliseconds)); + } +} diff --git a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs index b749162..01c64de 100644 --- a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs +++ b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs @@ -167,6 +167,9 @@ public sealed class FileRWTool : IAgentTool var relativePath = Path.GetRelativePath(rootPath, fullPath) .Replace('\\', '/').TrimStart('/'); + // GetProtectedPaths hängt an jeden Eintrag ein "/" an, deshalb greift + // StartsWith hier auf einer echten Verzeichnisgrenze: "stocks/" trifft + // "stocks/x.json", aber nicht "stocks-alt/x.json". return protectedPaths.Any(pp => relativePath.StartsWith(pp, StringComparison.OrdinalIgnoreCase) || relativePath.Equals(pp.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)); @@ -200,18 +203,9 @@ public sealed class FileRWTool : IAgentTool private string GetAndValidatePath(JsonElement input, string rootPath, string workspace, bool checkExtension, AgentToolContext context) { var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : ""; - if (string.IsNullOrWhiteSpace(relativePath)) - { - relativePath = "."; - } - var fullPath = Path.GetFullPath(Path.Combine(rootPath, relativePath)); - - // Path Traversal Check - if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) - { - throw new UnauthorizedAccessException($"Zugriff verweigert: Der Pfad liegt außerhalb des {workspace} Workspaces."); - } + // Prüft Ausbruch aus dem Workspace, absolute Pfade und Alternate Data Streams. + var fullPath = WorkspacePath.Resolve(rootPath, relativePath, workspace); // Extension Check if (checkExtension && !Directory.Exists(fullPath)) diff --git a/src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs b/src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs new file mode 100644 index 0000000..b45f05c --- /dev/null +++ b/src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs @@ -0,0 +1,83 @@ +namespace ClawdDotNet.Tools.FileRW; + +/// +/// Löst relative Pfade innerhalb eines Workspace auf und stellt sicher, dass sie +/// diesen nicht verlassen. +/// +/// Hintergrund (S6): Die Prüfung verglich vorher nur die Zeichenkette: +/// +/// fullPath.StartsWith(rootPath, OrdinalIgnoreCase) +/// +/// Ohne abschließenden Verzeichnistrenner erlaubte ein Root wie +/// "…\Agent-X\Workspace" damit auch Zugriffe auf "…\Agent-X\Workspace-Backup\…" — +/// ein fremdes Verzeichnis, das zufällig mit demselben Präfix beginnt. +/// +public static class WorkspacePath +{ + /// + /// Setzt auf auf und prüft, + /// dass das Ergebnis innerhalb des Workspace liegt. + /// + /// Wenn der Pfad ausbricht. + public static string Resolve(string root, string? relativePath, string workspaceName) + { + var value = string.IsNullOrWhiteSpace(relativePath) ? "." : relativePath.Trim(); + + // Absolute Pfade und UNC-Freigaben würden Path.Combine den Root verwerfen lassen. + if (Path.IsPathRooted(value) || value.StartsWith(@"\\") || value.StartsWith("//")) + { + throw new UnauthorizedAccessException( + $"Zugriff verweigert: Absolute Pfade sind im {workspaceName} Workspace nicht erlaubt."); + } + + // Alternate Data Streams (datei.txt:versteckt) umgehen die Endungsprüfung. + if (value.Contains(':')) + { + throw new UnauthorizedAccessException( + $"Zugriff verweigert: Der Pfad enthält ein unzulässiges Zeichen (':')."); + } + + var normalizedRoot = NormalizeDirectory(root); + var fullPath = Path.GetFullPath(Path.Combine(normalizedRoot, value)); + + if (!IsInside(fullPath, normalizedRoot)) + { + throw new UnauthorizedAccessException( + $"Zugriff verweigert: Der Pfad liegt außerhalb des {workspaceName} Workspaces."); + } + + return fullPath; + } + + /// + /// Prüft, ob im Verzeichnis liegt. + /// Der Vergleich erfolgt auf Verzeichnisgrenzen, nicht auf Zeichenketten-Präfixen. + /// + public static bool IsInside(string candidate, string root) + { + var normalizedRoot = NormalizeDirectory(root); + var normalizedCandidate = Path.GetFullPath(candidate); + + // Der Root selbst gilt als innerhalb. + if (string.Equals( + normalizedCandidate.TrimEnd(Path.DirectorySeparatorChar), + normalizedRoot.TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Der Trenner ist entscheidend: Ohne ihn gälte "…\Workspace-Backup" als + // Teil von "…\Workspace". + return normalizedCandidate.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase); + } + + /// Absoluter Pfad mit genau einem abschließenden Trenner. + private static string NormalizeDirectory(string path) + { + var full = Path.GetFullPath(path); + return full.EndsWith(Path.DirectorySeparatorChar) + ? full + : full + Path.DirectorySeparatorChar; + } +} diff --git a/src/ClawdDotNet.Tools.WebFetch/UrlGuard.cs b/src/ClawdDotNet.Tools.WebFetch/UrlGuard.cs new file mode 100644 index 0000000..e1b8f23 --- /dev/null +++ b/src/ClawdDotNet.Tools.WebFetch/UrlGuard.cs @@ -0,0 +1,156 @@ +using System.Net; +using System.Net.Sockets; + +namespace ClawdDotNet.Tools.WebFetch; + +/// +/// Prüft Adressen, bevor das Tool sie abruft. +/// +/// Hintergrund (S5): Die Domain-Whitelist wurde nur auf die ursprüngliche URL angewendet. +/// HttpClient folgt Weiterleitungen standardmäßig selbst — eine erlaubte Domain konnte +/// damit auf beliebige interne Adressen weiterleiten (Router, NAS, Git-Server im LAN, +/// Cloud-Metadatendienste unter 169.254.169.254). Der Agent hätte deren Inhalt +/// zurückgeliefert. +/// +/// Deshalb: Weiterleitungen werden nicht mehr automatisch verfolgt, sondern einzeln +/// aufgelöst und JEDER Zwischenschritt erneut geprüft. +/// +public static class UrlGuard +{ + public static bool TryValidate( + string? url, + IReadOnlyCollection allowedDomains, + out Uri? uri, + out string? error) + { + uri = null; + error = null; + + if (string.IsNullOrWhiteSpace(url)) + { + error = "Leere Adresse."; + return false; + } + + if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var parsed)) + { + error = "Keine gültige absolute URL."; + return false; + } + + if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) + { + error = $"Schema '{parsed.Scheme}' ist nicht erlaubt — nur http und https."; + return false; + } + + if (IsPrivateOrLocal(parsed.Host)) + { + error = $"Adresse '{parsed.Host}' liegt im lokalen oder privaten Netz und ist gesperrt."; + return false; + } + + if (!IsDomainAllowed(parsed.Host, allowedDomains)) + { + error = $"Domain '{parsed.Host}' steht nicht auf der Whitelist dieses Agenten."; + return false; + } + + uri = parsed; + return true; + } + + /// + /// Prüft den Host gegen die Whitelist. Erlaubt ist der Eintrag selbst und jede + /// Subdomain davon. + /// + /// Die frühere Fassung entfernte "www." mit einem Ersetzen über die ganze + /// Zeichenkette — aus "mywww.example.com" wurde dabei "myexample.com". + /// + public static bool IsDomainAllowed(string host, IReadOnlyCollection allowedDomains) + { + if (allowedDomains.Count == 0) + return false; + + var normalizedHost = NormalizeHost(host); + + foreach (var entry in allowedDomains) + { + if (string.IsNullOrWhiteSpace(entry)) + continue; + + var domain = NormalizeHost(entry); + + if (normalizedHost.Equals(domain, StringComparison.OrdinalIgnoreCase)) + return true; + + // Echte Subdomain — der Punkt verhindert, dass "example.com.attacker.net" + // als Treffer für "example.com" durchgeht. + if (normalizedHost.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static string NormalizeHost(string host) + { + var value = host.Trim().TrimEnd('.').ToLowerInvariant(); + return value.StartsWith("www.", StringComparison.Ordinal) ? value[4..] : value; + } + + /// + /// Erkennt Adressen im eigenen oder privaten Netz — einschließlich der + /// Metadatendienste von Cloud-Anbietern. + /// + public static bool IsPrivateOrLocal(string host) + { + var value = host.Trim().Trim('[', ']').ToLowerInvariant(); + + if (value is "localhost" || value.EndsWith(".localhost", StringComparison.Ordinal) + || value.EndsWith(".local", StringComparison.Ordinal) + || value.EndsWith(".internal", StringComparison.Ordinal)) + { + return true; + } + + if (!IPAddress.TryParse(value, out var ip)) + return false; + + if (IPAddress.IsLoopback(ip)) + return true; + + if (ip.AddressFamily == AddressFamily.InterNetwork) + { + var b = ip.GetAddressBytes(); + return b[0] switch + { + 10 => true, // 10.0.0.0/8 + 127 => true, // 127.0.0.0/8 + 0 => true, // 0.0.0.0/8 + 169 when b[1] == 254 => true, // 169.254.0.0/16 (Metadaten) + 172 when b[1] >= 16 && b[1] <= 31 => true, // 172.16.0.0/12 + 192 when b[1] == 168 => true, // 192.168.0.0/16 + 100 when b[1] >= 64 && b[1] <= 127 => true, // 100.64.0.0/10 (CGNAT) + _ => false + }; + } + + if (ip.AddressFamily == AddressFamily.InterNetworkV6) + { + if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal) + return true; + + // Unique Local Addresses fc00::/7 + var b = ip.GetAddressBytes(); + if ((b[0] & 0xFE) == 0xFC) + return true; + + // IPv4-gemappte Adressen erneut als IPv4 prüfen + if (ip.IsIPv4MappedToIPv6) + return IsPrivateOrLocal(ip.MapToIPv4().ToString()); + } + + return false; + } +} diff --git a/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs b/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs index 3b900f3..2bba3bd 100644 --- a/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs +++ b/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs @@ -54,16 +54,14 @@ public sealed class WebFetchTool : IAgentTool ? ad.EnumerateArray().Select(x => x.GetString()!).ToList() : (config.GetValueOrDefault("allowedDomains") as IEnumerable)?.ToList() ?? new List(); - // Domain-Whitelist prüfen - var uri = new Uri(url); - var host = uri.Host.Replace("www.", "").ToLowerInvariant(); - if (!allowedDomains.Any(d => host == d.ToLowerInvariant() || host.EndsWith("." + d.ToLowerInvariant()))) - return ToolResult.Fail($"Domain '{host}' nicht in der Whitelist dieses Agenten."); + // Adresse prüfen: Schema, privates Netz, Whitelist + if (!UrlGuard.TryValidate(url, allowedDomains, out _, out var urlError)) + return ToolResult.Fail(urlError!); return action switch { - "fetch" => await FetchPageAsync(url, config, ct), - "rss" => await FetchRssAsync(url, ct), + "fetch" => await FetchPageAsync(url, allowedDomains, config, ct), + "rss" => await FetchRssAsync(url, allowedDomains, ct), _ => ToolResult.Fail($"Unbekannte Aktion: {action}") }; } @@ -74,12 +72,19 @@ public sealed class WebFetchTool : IAgentTool } } - private async Task FetchPageAsync(string url, IReadOnlyDictionary config, CancellationToken ct) + private async Task FetchPageAsync( + string url, IReadOnlyCollection allowedDomains, + IReadOnlyDictionary config, CancellationToken ct) { using var http = CreateHttpClient(config); var fetchedAt = DateTime.UtcNow; - using var response = await http.GetAsync(url, ct); + var (response, redirectError) = await GetFollowingRedirectsAsync(http, url, allowedDomains, ct); + if (redirectError is not null) + return redirectError; + + using var _ = response!; + if (!response.IsSuccessStatusCode) return ToolResult.Fail($"Seite nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); @@ -104,14 +109,18 @@ public sealed class WebFetchTool : IAgentTool return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); } - private async Task FetchRssAsync(string url, CancellationToken ct) + private async Task FetchRssAsync( + string url, IReadOnlyCollection allowedDomains, CancellationToken ct) { - using var http = new HttpClient(); - http.DefaultRequestHeaders.UserAgent.ParseAdd("ClawdDotNet-Agent/1.0"); - http.Timeout = TimeSpan.FromSeconds(30); + using var http = CreateHttpClient(new Dictionary()); var fetchedAt = DateTime.UtcNow; - using var response = await http.GetAsync(url, ct); + var (response, redirectError) = await GetFollowingRedirectsAsync(http, url, allowedDomains, ct); + if (redirectError is not null) + return redirectError; + + using var _ = response!; + if (!response.IsSuccessStatusCode) return ToolResult.Fail($"RSS-Feed nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); @@ -201,9 +210,59 @@ public sealed class WebFetchTool : IAgentTool return null; } + private const int MaxRedirects = 5; + + /// + /// Folgt Weiterleitungen selbst und prüft jedes Ziel erneut gegen Whitelist und + /// Netzsperren. HttpClient würde sonst ungefragt bis ins lokale Netz folgen. + /// + private static async Task<(HttpResponseMessage? Response, ToolResult? Error)> GetFollowingRedirectsAsync( + HttpClient http, string url, IReadOnlyCollection allowedDomains, CancellationToken ct) + { + var current = url; + + for (var hop = 0; hop <= MaxRedirects; hop++) + { + if (!UrlGuard.TryValidate(current, allowedDomains, out var uri, out var error)) + { + var context = hop == 0 ? "" : $" (Weiterleitung {hop} von {url})"; + return (null, ToolResult.Fail($"{error}{context}")); + } + + var response = await http.GetAsync(uri, ct); + + if (!IsRedirect(response.StatusCode)) + return (response, null); + + var location = response.Headers.Location; + response.Dispose(); + + if (location is null) + return (null, ToolResult.Fail($"Weiterleitung ohne Zieladresse bei {current}.")); + + // Relative Weiterleitungen gegen die aktuelle Adresse auflösen. + current = location.IsAbsoluteUri + ? location.ToString() + : new Uri(uri!, location).ToString(); + } + + return (null, ToolResult.Fail( + $"Mehr als {MaxRedirects} Weiterleitungen ausgehend von {url} — abgebrochen.")); + } + + private static bool IsRedirect(HttpStatusCode status) => status is + HttpStatusCode.MovedPermanently or + HttpStatusCode.Found or + HttpStatusCode.SeeOther or + HttpStatusCode.TemporaryRedirect or + HttpStatusCode.PermanentRedirect; + private static HttpClient CreateHttpClient(IReadOnlyDictionary config) { - var client = new HttpClient(); + // Weiterleitungen bewusst NICHT automatisch verfolgen — sie werden einzeln + // aufgelöst und geprüft. + var handler = new HttpClientHandler { AllowAutoRedirect = false }; + var client = new HttpClient(handler, disposeHandler: true); var timeout = GetConfigInt(config, "timeoutSeconds", 15); client.Timeout = TimeSpan.FromSeconds(timeout); client.DefaultRequestHeaders.UserAgent.ParseAdd( diff --git a/tests/ClawdDotNet.Core.Tests/Api/RetryTests.cs b/tests/ClawdDotNet.Core.Tests/Api/RetryTests.cs new file mode 100644 index 0000000..f08410d --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Api/RetryTests.cs @@ -0,0 +1,213 @@ +using System.Net; +using ClawdDotNet.Core.Api; +using ClawdDotNet.Core.Api.Models; +using ClawdDotNet.Core.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Api; + +/// +/// B12 aus der Bestandsaufnahme: Der Client warf bei jedem Nicht-2xx sofort. Ein +/// einzelnes HTTP 429 beendete damit einen kompletten geplanten Lauf, obwohl +/// Rate-Limits und kurze 5xx bei OpenRouter Normalbetrieb sind. +/// +public sealed class RetryPolicyTests +{ + [Theory] + [InlineData(HttpStatusCode.TooManyRequests, true)] + [InlineData(HttpStatusCode.RequestTimeout, true)] + [InlineData(HttpStatusCode.InternalServerError, true)] + [InlineData(HttpStatusCode.BadGateway, true)] + [InlineData(HttpStatusCode.ServiceUnavailable, true)] + [InlineData(HttpStatusCode.GatewayTimeout, true)] + [InlineData(HttpStatusCode.BadRequest, false)] + [InlineData(HttpStatusCode.Unauthorized, false)] + [InlineData(HttpStatusCode.Forbidden, false)] + [InlineData(HttpStatusCode.NotFound, false)] + [InlineData(HttpStatusCode.Conflict, false)] + public void Nur_voruebergehende_Stoerungen_werden_wiederholt(HttpStatusCode status, bool expected) + { + // Ein 400 oder 401 wiederholt sich nicht von selbst — dafür zu warten + // verzögert nur die Fehlermeldung. + RetryPolicy.Default.ShouldRetry(status).ShouldBe(expected); + } + + [Fact] + public void Die_Wartezeit_waechst_exponentiell() + { + var policy = new RetryPolicy { BaseDelay = TimeSpan.FromSeconds(1), JitterFactor = 0 }; + + policy.GetDelay(1).ShouldBe(TimeSpan.FromSeconds(1)); + policy.GetDelay(2).ShouldBe(TimeSpan.FromSeconds(2)); + policy.GetDelay(3).ShouldBe(TimeSpan.FromSeconds(4)); + policy.GetDelay(4).ShouldBe(TimeSpan.FromSeconds(8)); + } + + [Fact] + public void Die_Wartezeit_ist_nach_oben_begrenzt() + { + var policy = new RetryPolicy + { + BaseDelay = TimeSpan.FromSeconds(1), + MaxDelay = TimeSpan.FromSeconds(10), + JitterFactor = 0 + }; + + policy.GetDelay(20).ShouldBe(TimeSpan.FromSeconds(10)); + } + + [Fact] + public void Ein_RetryAfter_des_Servers_hat_Vorrang() + { + var policy = new RetryPolicy { BaseDelay = TimeSpan.FromSeconds(1), JitterFactor = 0 }; + + policy.GetDelay(1, TimeSpan.FromSeconds(7)).ShouldBe(TimeSpan.FromSeconds(7)); + } + + [Fact] + public void Auch_ein_RetryAfter_wird_gedeckelt() + { + // Ein Server könnte Stunden verlangen — so lange darf kein Lauf blockieren. + var policy = new RetryPolicy { MaxDelay = TimeSpan.FromSeconds(30) }; + + policy.GetDelay(1, TimeSpan.FromHours(1)).ShouldBe(TimeSpan.FromSeconds(30)); + } + + [Theory] + [InlineData(0.0)] + [InlineData(0.5)] + [InlineData(1.0)] + public void Die_Streuung_bleibt_im_erwarteten_Rahmen(double sample) + { + var policy = new RetryPolicy { BaseDelay = TimeSpan.FromSeconds(4), JitterFactor = 0.25 }; + + var delay = policy.GetDelay(1, null, sample); + + delay.TotalSeconds.ShouldBeInRange(3.0, 5.0); + } +} + +public sealed class OpenRouterClientRetryTests +{ + private static ChatRequest SimpleRequest() => new() + { + Model = "test/model", + Messages = [ChatMessage.User("Hallo")] + }; + + private const string SuccessBody = + """{"id":"x","choices":[{"index":0,"message":{"role":"assistant","content":"Hi"}}]}"""; + + /// Wartet nicht wirklich — sonst dauerte die Suite Sekunden. + private static Task NoWait(TimeSpan _, CancellationToken __) => Task.CompletedTask; + + private static OpenRouterClient CreateClient(FakeHttpMessageHandler handler, RetryPolicy? policy = null) + { + var http = new HttpClient(handler) { BaseAddress = new Uri("https://openrouter.ai/api/v1/") }; + return new OpenRouterClient("sk-test", NullLogger.Instance, http, policy ?? RetryPolicy.Default, NoWait); + } + + [Fact] + public async Task Ein_RateLimit_beendet_den_Lauf_nicht_mehr() + { + var handler = new FakeHttpMessageHandler() + .Responds(HttpStatusCode.TooManyRequests, """{"error":"rate limit"}""") + .RespondsWithSuccess(SuccessBody); + + using var client = CreateClient(handler); + + var response = await client.CompleteAsync(SimpleRequest(), default); + + response.Choices[0].Message.Content.ShouldBe("Hi"); + handler.RequestCount.ShouldBe(2); + } + + [Fact] + public async Task Mehrere_Stoerungen_hintereinander_werden_ueberbrueckt() + { + var handler = new FakeHttpMessageHandler() + .Responds(HttpStatusCode.ServiceUnavailable) + .Responds(HttpStatusCode.BadGateway) + .RespondsWithSuccess(SuccessBody); + + using var client = CreateClient(handler); + + await client.CompleteAsync(SimpleRequest(), default); + + handler.RequestCount.ShouldBe(3); + } + + [Fact] + public async Task Nach_dem_letzten_Versuch_wird_der_Fehler_gemeldet() + { + var policy = new RetryPolicy { MaxAttempts = 3 }; + var handler = new FakeHttpMessageHandler() + .Responds(HttpStatusCode.TooManyRequests) + .Responds(HttpStatusCode.TooManyRequests) + .Responds(HttpStatusCode.TooManyRequests); + + using var client = CreateClient(handler, policy); + + var ex = await Should.ThrowAsync( + () => client.CompleteAsync(SimpleRequest(), default)); + + ex.StatusCode.ShouldBe(429); + handler.RequestCount.ShouldBe(3); + } + + [Fact] + public async Task Ein_dauerhafter_Fehler_wird_nicht_wiederholt() + { + // Ein 401 wird durch Warten nicht besser — sofort melden. + var handler = new FakeHttpMessageHandler() + .Responds(HttpStatusCode.Unauthorized, """{"error":"invalid key"}"""); + + using var client = CreateClient(handler); + + await Should.ThrowAsync( + () => client.CompleteAsync(SimpleRequest(), default)); + + handler.RequestCount.ShouldBe(1); + } + + [Fact] + public async Task Netzwerkfehler_werden_wiederholt() + { + var handler = new FakeHttpMessageHandler() + .Throws(new HttpRequestException("Verbindung abgebrochen")) + .RespondsWithSuccess(SuccessBody); + + using var client = CreateClient(handler); + + await client.CompleteAsync(SimpleRequest(), default); + + handler.RequestCount.ShouldBe(2); + } + + [Fact] + public async Task Ein_Abbruch_durch_den_Benutzer_wird_nicht_wiederholt() + { + var handler = new FakeHttpMessageHandler().Responds(HttpStatusCode.TooManyRequests); + using var client = CreateClient(handler); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Should.ThrowAsync( + () => client.CompleteAsync(SimpleRequest(), cts.Token)); + + handler.RequestCount.ShouldBe(0); + } + + [Fact] + public async Task Ein_erfolgreicher_erster_Versuch_loest_keine_Wiederholung_aus() + { + var handler = new FakeHttpMessageHandler().RespondsWithSuccess(SuccessBody); + using var client = CreateClient(handler); + + await client.CompleteAsync(SimpleRequest(), default); + + handler.RequestCount.ShouldBe(1); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeHttpMessageHandler.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeHttpMessageHandler.cs new file mode 100644 index 0000000..5168a75 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeHttpMessageHandler.cs @@ -0,0 +1,49 @@ +using System.Net; + +namespace ClawdDotNet.Core.Tests.Infrastructure; + +/// +/// Liefert vorprogrammierte HTTP-Antworten, ohne dass ein Netzwerkzugriff stattfindet. +/// +internal sealed class FakeHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue> _responses = new(); + + public int RequestCount { get; private set; } + + public FakeHttpMessageHandler Responds(HttpStatusCode status, string body = "{}", TimeSpan? retryAfter = null) + { + _responses.Enqueue(() => + { + var response = new HttpResponseMessage(status) + { + Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + }; + if (retryAfter is { } delta) + response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(delta); + return response; + }); + return this; + } + + public FakeHttpMessageHandler RespondsWithSuccess(string body = """{"id":"x","choices":[]}""") + => Responds(HttpStatusCode.OK, body); + + public FakeHttpMessageHandler Throws(Exception ex) + { + _responses.Enqueue(() => throw ex); + return this; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestCount++; + + if (_responses.Count == 0) + throw new InvalidOperationException( + $"FakeHttpMessageHandler: unerwartete Anfrage Nr. {RequestCount}."); + + return Task.FromResult(_responses.Dequeue()()); + } +} diff --git a/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj b/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj index 03fb159..6906556 100644 --- a/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj +++ b/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs b/tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs new file mode 100644 index 0000000..5bb51c5 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs @@ -0,0 +1,131 @@ +using ClawdDotNet.Tools.FileRW; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.FileRW; + +/// +/// S6 aus der Bestandsaufnahme: Die Pfadprüfung verglich nur Zeichenketten-Präfixe. +/// Ohne abschließenden Verzeichnistrenner erlaubte ein Root "…\Workspace" damit auch +/// "…\Workspace-Backup\…". +/// +/// Das Dateisystem wird hier bewusst NICHT abstrahiert — die Tests sollen die echte +/// Windows-Pfadsemantik prüfen (.., UNC, Alternate Data Streams, abschließende Punkte). +/// Eine Abstraktion würde genau die Fehlerklasse verstecken, um die es geht. +/// +public sealed class WorkspacePathTests : IDisposable +{ + private readonly string _root; + + public WorkspacePathTests() + { + _root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Workspace"); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + try { Directory.Delete(Path.GetDirectoryName(_root)!, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private string Resolve(string? relative) => WorkspacePath.Resolve(_root, relative, "personal"); + + // ═══════════════════════════════════════════════════════════ + // Ausbruchsversuche + // ═══════════════════════════════════════════════════════════ + + [Theory] + [InlineData("../../../Windows/System32/drivers/etc/hosts")] + [InlineData(@"..\..\evil.txt")] + [InlineData("unterordner/../../../ausserhalb.txt")] + [InlineData("./../../evil.txt")] + [InlineData("..")] + public void Relative_Ausbrueche_werden_abgelehnt(string path) + { + Should.Throw(() => Resolve(path)); + } + + [Theory] + [InlineData(@"C:\Windows\System32\config\SAM")] + [InlineData(@"\\server\share\evil.txt")] + [InlineData("//server/share/evil.txt")] + [InlineData(@"C:\temp\datei.txt")] + public void Absolute_Pfade_und_UNC_Freigaben_werden_abgelehnt(string path) + { + Should.Throw(() => Resolve(path)); + } + + [Theory] + [InlineData("datei.txt:versteckt")] + [InlineData("datei.txt:$DATA")] + public void Alternate_Data_Streams_werden_abgelehnt(string path) + { + // Ein ADS umgeht sonst die Endungsprüfung: "x.txt:evil.exe". + Should.Throw(() => Resolve(path)); + } + + /// + /// Der eigentliche Kern von S6: ein Nachbarverzeichnis mit gleichem Präfix. + /// + [Fact] + public void Ein_Nachbarverzeichnis_mit_gleichem_Praefix_gilt_als_ausserhalb() + { + var backup = _root + "-Backup"; + + WorkspacePath.IsInside(Path.Combine(backup, "geheim.txt"), _root).ShouldBeFalse(); + } + + [Fact] + public void Auch_bei_Rootangabe_mit_Trenner_bleibt_das_Nachbarverzeichnis_aussen() + { + var rootWithSeparator = _root + Path.DirectorySeparatorChar; + + WorkspacePath.IsInside(_root + "-Backup" + Path.DirectorySeparatorChar + "x.txt", rootWithSeparator) + .ShouldBeFalse(); + } + + // ═══════════════════════════════════════════════════════════ + // Gegenproben — normale Nutzung muss funktionieren + // ═══════════════════════════════════════════════════════════ + + [Theory] + [InlineData("bericht.md")] + [InlineData("unterordner/bericht.md")] + [InlineData("a/b/c/tief.json")] + [InlineData("./bericht.md")] + [InlineData("unterordner/../bericht.md")] + public void Pfade_innerhalb_des_Workspace_werden_aufgeloest(string path) + { + var resolved = Resolve(path); + + WorkspacePath.IsInside(resolved, _root).ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(".")] + public void Leere_Angaben_ergeben_das_Wurzelverzeichnis(string? path) + { + var resolved = Resolve(path); + + resolved.TrimEnd(Path.DirectorySeparatorChar) + .ShouldBe(Path.GetFullPath(_root).TrimEnd(Path.DirectorySeparatorChar)); + } + + [Fact] + public void Das_Wurzelverzeichnis_selbst_gilt_als_innerhalb() + { + WorkspacePath.IsInside(_root, _root).ShouldBeTrue(); + } + + [Fact] + public void Ein_Unterverzeichnis_mit_aehnlichem_Namen_bleibt_innerhalb() + { + // Gegenprobe zur Präfix-Regel: Innerhalb des Roots ist alles erlaubt. + var inner = Path.Combine(_root, "Workspace-Backup", "x.txt"); + + WorkspacePath.IsInside(inner, _root).ShouldBeTrue(); + } +} diff --git a/tests/ClawdDotNet.Tools.Tests/WebFetch/UrlGuardTests.cs b/tests/ClawdDotNet.Tools.Tests/WebFetch/UrlGuardTests.cs new file mode 100644 index 0000000..277c723 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/WebFetch/UrlGuardTests.cs @@ -0,0 +1,130 @@ +using ClawdDotNet.Tools.WebFetch; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.WebFetch; + +/// +/// S5 aus der Bestandsaufnahme: Die Whitelist wurde nur auf die Ausgangs-URL angewendet, +/// HttpClient folgte Weiterleitungen aber selbst. Eine erlaubte Domain konnte damit ins +/// lokale Netz weiterleiten — Router, NAS, der eigene Git-Server, Cloud-Metadatendienste. +/// +public sealed class UrlGuardTests +{ + private static readonly string[] Allowed = ["example.com", "reuters.com"]; + + private static bool Validate(string url, out string? error) + => UrlGuard.TryValidate(url, Allowed, out _, out error); + + // ═══════════════════════════════════════════════════════════ + // Netzsperren + // ═══════════════════════════════════════════════════════════ + + [Theory] + [InlineData("http://169.254.169.254/latest/meta-data/")] // Cloud-Metadaten + [InlineData("http://127.0.0.1:8080/")] + [InlineData("http://localhost:8418/")] + [InlineData("http://192.168.178.10:8418/Richard/ClawdDotNet.git")] + [InlineData("http://10.0.0.5/admin")] + [InlineData("http://172.16.4.2/")] + [InlineData("http://172.31.255.255/")] + [InlineData("http://0.0.0.0/")] + [InlineData("http://100.64.1.1/")] + [InlineData("http://[::1]/")] + [InlineData("http://[fd00::1]/")] + [InlineData("http://nas.local/")] + [InlineData("http://dienst.internal/")] + public void Adressen_im_lokalen_oder_privaten_Netz_werden_gesperrt(string url) + { + Validate(url, out var error).ShouldBeFalse($"'{url}' muss gesperrt sein"); + error.ShouldNotBeNull(); + } + + [Theory] + [InlineData("172.15.0.1")] // knapp unterhalb von 172.16/12 + [InlineData("172.32.0.1")] // knapp oberhalb + [InlineData("11.0.0.1")] + [InlineData("8.8.8.8")] + public void Oeffentliche_Adressen_gelten_nicht_als_privat(string host) + { + UrlGuard.IsPrivateOrLocal(host).ShouldBeFalse(); + } + + // ═══════════════════════════════════════════════════════════ + // Schema + // ═══════════════════════════════════════════════════════════ + + [Theory] + [InlineData("file:///C:/Windows/win.ini")] + [InlineData("ftp://example.com/datei")] + [InlineData("gopher://example.com/")] + public void Nur_http_und_https_sind_erlaubt(string url) + { + Validate(url, out _).ShouldBeFalse(); + } + + // ═══════════════════════════════════════════════════════════ + // Whitelist + // ═══════════════════════════════════════════════════════════ + + [Theory] + [InlineData("https://example.com/artikel")] + [InlineData("https://www.example.com/artikel")] + [InlineData("https://news.example.com/artikel")] + [InlineData("https://a.b.example.com/x")] + [InlineData("https://reuters.com/")] + public void Erlaubte_Domains_und_ihre_Subdomains_gehen_durch(string url) + { + Validate(url, out var error).ShouldBeTrue(error); + } + + [Theory] + [InlineData("https://example.com.attacker.net/x")] + [InlineData("https://notexample.com/x")] + [InlineData("https://evil.com/x")] + [InlineData("https://exampleXcom/x")] + public void Aehnlich_aussehende_Domains_werden_abgelehnt(string url) + { + Validate(url, out _).ShouldBeFalse(); + } + + [Fact] + public void Ohne_Whitelist_wird_nichts_durchgelassen() + { + UrlGuard.TryValidate("https://example.com", [], out _, out _).ShouldBeFalse(); + } + + /// + /// Die alte Fassung entfernte "www." per Ersetzen über die ganze Zeichenkette: + /// aus "mywww.example.com" wurde "myexample.com". + /// + [Fact] + public void Die_www_Behandlung_verstuemmelt_keine_anderen_Hostnamen() + { + UrlGuard.IsDomainAllowed("mywww.example.com", Allowed).ShouldBeTrue(); + UrlGuard.IsDomainAllowed("wwwxexample.com", Allowed).ShouldBeFalse(); + } + + [Fact] + public void Ein_abschliessender_Punkt_im_Host_umgeht_die_Whitelist_nicht() + { + // "example.com." ist DNS-technisch derselbe Host. + UrlGuard.IsDomainAllowed("evil.com.", Allowed).ShouldBeFalse(); + UrlGuard.IsDomainAllowed("example.com.", Allowed).ShouldBeTrue(); + } + + [Fact] + public void Grossschreibung_im_Host_spielt_keine_Rolle() + { + Validate("https://EXAMPLE.COM/x", out var error).ShouldBeTrue(error); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("kein-url")] + [InlineData("/nur/ein/pfad")] + public void Ungueltige_Eingaben_werden_abgelehnt(string? url) + { + UrlGuard.TryValidate(url, Allowed, out _, out _).ShouldBeFalse(); + } +}