Befunde aus dem Testbetrieb auf der Linux-VM behoben (#572)

- #578: Abgebrochene Laeufe (Schrittgrenze, Zeitueberschreitung, Abbruch,
  Ausnahme) tragen ihre bezahlten Token - vorher 0 in RunUsage, fuer das
  Budget unsichtbar.
- #580: WebFetch prueft die aufgeloeste Zieladresse beim Verbinden; ein
  erlaubter Name, der auf 127.0.0.1 aufloest, wird gesperrt.
- #575: Dateien entstehen unter Unix mit 0600 (AtomicFile, Protokolle,
  FileRW-Anhaengen, state.db), Protokollverzeichnisse mit 0700.
- #576: SIGTERM/SIGINT beenden auch mit Fenster geordnet.
- #593: OpenRouter-Statusdienst wird gestartet.
- #589: Auftragsnachricht nennt aktuelle Zeit, Zeitzone und Termin.
- #581: Unvollstaendiges Argument-JSON erreicht das Werkzeug nicht mehr;
  das Modell bekommt eine verstaendliche Rueckmeldung statt Parsertext.
- #591: Weboberflaeche liest die Felder, die API und Ereignisstrom liefern
  (Freigaben, Protokoll, Taskboard mit Termin, Werkzeug-Einstellungen,
  Tokennutzung); Ereignisstrom in camelCase.
- #592: Chat zeigt Nachrichten nicht mehr doppelt.
- #536 (teilweise): A2A-Antworten tragen Absender und Empfaenger, Parser
  wirft nicht mehr; Datums- und Volltextfilter in Agenten-Chats.
- deploy/publish.py: Packager-Pfad net10.0, Schalter --agent-file.
- Version 0.1.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-14 15:40:56 +02:00
co-authored by Claude Opus 5
parent e2567a466f
commit 6581fd0187
29 changed files with 1128 additions and 190 deletions
+77 -4
View File
@@ -1,4 +1,5 @@
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
namespace ClawdDotNet.Tools.WebFetch;
@@ -14,6 +15,14 @@ namespace ClawdDotNet.Tools.WebFetch;
///
/// Deshalb: Weiterleitungen werden nicht mehr automatisch verfolgt, sondern einzeln
/// aufgelöst und JEDER Zwischenschritt erneut geprüft.
///
/// <para><b>Und die Adresse wird dort geprüft, wo verbunden wird (#580).</b> Die Prüfung der
/// URL sieht nur den Namen. <c>localtest.me</c> steht als Name nirgends auf einer Sperrliste
/// und löst doch auf <c>127.0.0.1</c> auf — im Prüfdurchgang hat WebFetch genau so eine
/// Verbindung auf Loopback geöffnet. <see cref="ConnectAsync"/> löst den Namen selbst auf,
/// prüft jede Zieladresse und verbindet nur mit einer geprüften. Damit greift auch
/// DNS-Rebinding nicht mehr: Eine zweite Auflösung zwischen Prüfung und Verbindung gibt es
/// nicht.</para>
/// </summary>
public static class UrlGuard
{
@@ -114,9 +123,12 @@ public static class UrlGuard
return true;
}
if (!IPAddress.TryParse(value, out var ip))
return false;
return IPAddress.TryParse(value, out var ip) && IsPrivateOrLocal(ip);
}
/// <summary>Dieselbe Sperre für eine bereits aufgelöste Adresse.</summary>
public static bool IsPrivateOrLocal(IPAddress ip)
{
if (IPAddress.IsLoopback(ip))
return true;
@@ -138,7 +150,7 @@ public static class UrlGuard
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
{
if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal)
if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.Equals(IPAddress.IPv6None))
return true;
// Unique Local Addresses fc00::/7
@@ -148,9 +160,70 @@ public static class UrlGuard
// IPv4-gemappte Adressen erneut als IPv4 prüfen
if (ip.IsIPv4MappedToIPv6)
return IsPrivateOrLocal(ip.MapToIPv4().ToString());
return IsPrivateOrLocal(ip.MapToIPv4());
}
return false;
}
/// <summary>
/// Verbindungsaufbau für den <see cref="SocketsHttpHandler"/> von WebFetch (#580): erst
/// auflösen und prüfen, dann mit genau den geprüften Adressen verbinden.
/// </summary>
public static async ValueTask<Stream> ConnectAsync(
SocketsHttpConnectionContext context, CancellationToken ct)
{
var endpoint = context.DnsEndPoint;
var addresses = await ResolveAllowedAsync(
endpoint.Host, (host, token) => Dns.GetHostAddressesAsync(host, token), ct);
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(addresses, endpoint.Port, ct);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
/// <summary>
/// Löst einen Namen auf und gibt die Adressen nur zurück, wenn <b>keine</b> davon im
/// lokalen oder privaten Netz liegt.
///
/// <para>Alle und nicht „die erste erlaubte": Ein Name mit einer öffentlichen und einer
/// internen Adresse ist genau das Werkzeug, mit dem man eine Prüfung überlistet, die nur
/// eine Stichprobe nimmt.</para>
/// </summary>
/// <param name="resolve">Die Namensauflösung — austauschbar, damit sich die Sperre ohne
/// Netz prüfen lässt.</param>
public static async Task<IPAddress[]> ResolveAllowedAsync(
string host,
Func<string, CancellationToken, Task<IPAddress[]>> resolve,
CancellationToken ct)
{
var value = host.Trim().Trim('[', ']');
var addresses = IPAddress.TryParse(value, out var literal)
? [literal]
: await resolve(value, ct);
if (addresses.Length == 0)
throw new HttpRequestException($"Adresse '{host}' ließ sich nicht auflösen.");
foreach (var address in addresses)
{
if (IsPrivateOrLocal(address))
{
throw new HttpRequestException(
$"Adresse '{host}' löst auf {address} im lokalen oder privaten Netz auf und ist gesperrt.");
}
}
return addresses;
}
}
+10 -1
View File
@@ -295,7 +295,16 @@ public sealed class WebFetchTool : IAgentTool
{
// Weiterleitungen bewusst NICHT automatisch verfolgen — sie werden einzeln
// aufgelöst und geprüft.
var handler = new HttpClientHandler { AllowAutoRedirect = false };
//
// #580: Die Zieladresse wird beim Verbinden geprüft, nicht nur der Name in der URL.
// Ohne Proxy, weil über einen Proxy der Proxy auflöst und verbindet — die Prüfung
// sähe dann nur dessen Adresse, nie das eigentliche Ziel.
var handler = new SocketsHttpHandler
{
AllowAutoRedirect = false,
UseProxy = false,
ConnectCallback = UrlGuard.ConnectAsync
};
var client = new HttpClient(handler, disposeHandler: true);
var timeout = GetConfigInt(config, "timeoutSeconds", 15);
client.Timeout = TimeSpan.FromSeconds(timeout);