SSRF-Schutz, Pfadpruefung und Retry-Logik
S5 — Die Domain-Whitelist wurde nur auf die Ausgangs-URL angewendet, HttpClient folgte Weiterleitungen aber selbst. Eine erlaubte Domain konnte damit auf beliebige interne Adressen weiterleiten: Router, NAS, der Git-Server im LAN, Cloud-Metadatendienste. Der Agent haette deren Inhalt zurueckgeliefert. UrlGuard prueft Schema, private und lokale Netzbereiche sowie die Whitelist. AllowAutoRedirect ist abgeschaltet; Weiterleitungen werden einzeln aufgeloest und JEDER Zwischenschritt erneut geprueft, begrenzt auf fuenf Spruenge. Nebenbei behoben: Die alte www-Behandlung ersetzte die Zeichenfolge ueber den ganzen Hostnamen, aus mywww.example.com wurde myexample.com. Und die Subdomain-Pruefung achtet jetzt auf den Punkt, sodass example.com.attacker.net nicht mehr als Treffer fuer example.com durchgeht. S6 — Die Pfadpruefung in FileRW verglich nur Zeichenketten-Praefixe. Ohne abschliessenden Verzeichnistrenner erlaubte ein Root wie Agent-X\Workspace damit auch Zugriffe auf Agent-X\Workspace-Backup. WorkspacePath vergleicht jetzt auf Verzeichnisgrenzen und lehnt zusaetzlich absolute Pfade, UNC-Freigaben und Alternate Data Streams ab. Das Dateisystem wird in den Tests bewusst nicht abstrahiert — sie sollen die echte Windows-Pfadsemantik pruefen. Eine Abstraktion wuerde genau die Fehlerklasse verstecken, um die es geht. B12 — 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. RetryPolicy wiederholt 408/425/429/5xx mit exponentiellem Backoff und Streuung, respektiert ein Retry-After des Servers und deckelt die Wartezeit. Dauerhafte Fehler wie 401 werden nicht wiederholt. Die Wartefunktion ist injizierbar, damit die Tests nicht wirklich warten. 264 Tests gruen (116 Core, 148 Tools). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e5067cae70
commit
cbb8ac22bc
@@ -12,15 +12,26 @@ public sealed class OpenRouterClient : IChatCompletionClient, IDisposable
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger _logger;
|
||||
private readonly RetryPolicy _retry;
|
||||
|
||||
/// <summary>Wartefunktion — in Tests ersetzbar, damit nicht wirklich gewartet wird.</summary>
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _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<TimeSpan, CancellationToken, Task>? 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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, "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ruft die verfügbaren Modelle von OpenRouter ab (/models Endpoint).
|
||||
/// Gibt eine Liste von Modell-IDs zurück, sortiert nach Name.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Net;
|
||||
|
||||
namespace ClawdDotNet.Core.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class RetryPolicy
|
||||
{
|
||||
/// <summary>Gesamtzahl der Versuche, inklusive des ersten.</summary>
|
||||
public int MaxAttempts { get; init; } = 4;
|
||||
|
||||
public TimeSpan BaseDelay { get; init; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Anteil zufälliger Streuung, damit parallele Agenten nicht im Gleichtakt erneut anfragen.</summary>
|
||||
public double JitterFactor { get; init; } = 0.25;
|
||||
|
||||
public static RetryPolicy Default { get; } = new();
|
||||
|
||||
/// <summary>Kein Wiederholen — für Tests und Sonderfälle.</summary>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Wartezeit vor dem nächsten Versuch. <paramref name="attempt"/> 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace ClawdDotNet.Tools.FileRW;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class WorkspacePath
|
||||
{
|
||||
/// <summary>
|
||||
/// Setzt <paramref name="relativePath"/> auf <paramref name="root"/> auf und prüft,
|
||||
/// dass das Ergebnis innerhalb des Workspace liegt.
|
||||
/// </summary>
|
||||
/// <exception cref="UnauthorizedAccessException">Wenn der Pfad ausbricht.</exception>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob <paramref name="candidate"/> im Verzeichnis <paramref name="root"/> liegt.
|
||||
/// Der Vergleich erfolgt auf Verzeichnisgrenzen, nicht auf Zeichenketten-Präfixen.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Absoluter Pfad mit genau einem abschließenden Trenner.</summary>
|
||||
private static string NormalizeDirectory(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return full.EndsWith(Path.DirectorySeparatorChar)
|
||||
? full
|
||||
: full + Path.DirectorySeparatorChar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ClawdDotNet.Tools.WebFetch;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class UrlGuard
|
||||
{
|
||||
public static bool TryValidate(
|
||||
string? url,
|
||||
IReadOnlyCollection<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
public static bool IsDomainAllowed(string host, IReadOnlyCollection<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erkennt Adressen im eigenen oder privaten Netz — einschließlich der
|
||||
/// Metadatendienste von Cloud-Anbietern.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -54,16 +54,14 @@ public sealed class WebFetchTool : IAgentTool
|
||||
? ad.EnumerateArray().Select(x => x.GetString()!).ToList()
|
||||
: (config.GetValueOrDefault("allowedDomains") as IEnumerable<string>)?.ToList() ?? new List<string>();
|
||||
|
||||
// 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<ToolResult> FetchPageAsync(string url, IReadOnlyDictionary<string, object?> config, CancellationToken ct)
|
||||
private async Task<ToolResult> FetchPageAsync(
|
||||
string url, IReadOnlyCollection<string> allowedDomains,
|
||||
IReadOnlyDictionary<string, object?> 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<ToolResult> FetchRssAsync(string url, CancellationToken ct)
|
||||
private async Task<ToolResult> FetchRssAsync(
|
||||
string url, IReadOnlyCollection<string> 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<string, object?>());
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Folgt Weiterleitungen selbst und prüft jedes Ziel erneut gegen Whitelist und
|
||||
/// Netzsperren. HttpClient würde sonst ungefragt bis ins lokale Netz folgen.
|
||||
/// </summary>
|
||||
private static async Task<(HttpResponseMessage? Response, ToolResult? Error)> GetFollowingRedirectsAsync(
|
||||
HttpClient http, string url, IReadOnlyCollection<string> 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<string, object?> 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(
|
||||
|
||||
Reference in New Issue
Block a user