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
@@ -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;
/// <summary>
/// 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.
/// </summary>
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"}}]}""";
/// <summary>Wartet nicht wirklich — sonst dauerte die Suite Sekunden.</summary>
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<OpenRouterException>(
() => 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<OpenRouterException>(
() => 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<OperationCanceledException>(
() => 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);
}
}
@@ -0,0 +1,49 @@
using System.Net;
namespace ClawdDotNet.Core.Tests.Infrastructure;
/// <summary>
/// Liefert vorprogrammierte HTTP-Antworten, ohne dass ein Netzwerkzugriff stattfindet.
/// </summary>
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpResponseMessage>> _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<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
if (_responses.Count == 0)
throw new InvalidOperationException(
$"FakeHttpMessageHandler: unerwartete Anfrage Nr. {RequestCount}.");
return Task.FromResult(_responses.Dequeue()());
}
}