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
+80 -9
View File
@@ -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.
+71
View File
@@ -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));
}
}