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();
+ }
+}