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:
Richard
2026-07-28 00:24:03 +02:00
co-authored by Claude Opus 4.8
parent e5067cae70
commit cbb8ac22bc
12 changed files with 996 additions and 37 deletions
+156
View File
@@ -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;
}
}
+74 -15
View File
@@ -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(