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
@@ -23,6 +23,7 @@
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.DirectAPI\ClawdDotNet.Tools.DirectAPI.csproj" />
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.WebFetch\ClawdDotNet.Tools.WebFetch.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,131 @@
using ClawdDotNet.Tools.FileRW;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.FileRW;
/// <summary>
/// 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.
/// </summary>
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<UnauthorizedAccessException>(() => 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<UnauthorizedAccessException>(() => 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<UnauthorizedAccessException>(() => Resolve(path));
}
/// <summary>
/// Der eigentliche Kern von S6: ein Nachbarverzeichnis mit gleichem Präfix.
/// </summary>
[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();
}
}
@@ -0,0 +1,130 @@
using ClawdDotNet.Tools.WebFetch;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.WebFetch;
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// Die alte Fassung entfernte "www." per Ersetzen über die ganze Zeichenkette:
/// aus "mywww.example.com" wurde "myexample.com".
/// </summary>
[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();
}
}