Files
ClawdDotNet/src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs
T
RichardandClaude Opus 4.8 e5067cae70 Sicherheitsluecken S1, S2 und S3 schliessen
Neues Testprojekt tests/ClawdDotNet.Tools.Tests. Die Angriffsfaelle aus der
Bestandsaufnahme bleiben darin dauerhaft als Testfaelle dokumentiert — zusammen
mit Gegenproben, damit die Fixes nicht zu streng werden und legitime Nutzung
blockieren.

S3 — DirectAPI gab die abgerufene URL als Quelle an das Modell zurueck, samt
API-Schluessel im Query-String. Der Schluessel landete damit im
Konversationskontext, wurde bei jedem Folgeschritt erneut gesendet, in
ChatContext.json geschrieben und in die Logs uebernommen. UrlSanitizer maskiert
sensible Query-Parameter; auch die Fehlermeldungen sind betroffen und werden
bereinigt.

S2 — Die Kanal-/Video-Angabe wurde ungeprueft in eine Argument-Zeichenkette fuer
yt-dlp interpoliert. UseShellExecute=false verhindert Shell-Metazeichen, nicht
aber Options-Injection: yt-dlp kennt die Option --exec, die beliebige Befehle
ausfuehrt. Kritisch, weil der Agent untrusted Inhalte verarbeitet — eine
Prompt-Injection darin konnte ihn dazu bringen, genau so einen Wert zu setzen.

YouTubeUrl validiert Handles und URLs gegen die zulaessigen YouTube-Hosts und
lehnt alles ab, was mit einem Bindestrich beginnt. Die Argumente gehen jetzt
einzeln ueber ProcessStartInfo.ArgumentList, die Adresse steht hinter dem
Optionsende-Trenner. Der ffmpeg-Aufruf wurde ebenso umgestellt.

S1 — Die Tabellen-Whitelist suchte den erlaubten Namen als Teilzeichenkette
irgendwo im Statement, auch in Kommentaren. Bei einer Freigabe fuer prices
genuegte deshalb ein DELETE auf users mit einem Kommentar, der prices enthielt,
um eine beliebige Tabelle zu loeschen. Umgekehrt galten harmlose Abfragen, die
ein Schluesselwort nur als Wert enthielten, faelschlich als Schreibzugriff.

SqlGuard entfernt zuerst Kommentare und String-Literale, lehnt mehrere Statements
ab, bestimmt die Operation am ersten Schluesselwort und extrahiert Tabellennamen
gezielt hinter FROM/JOIN/INTO/UPDATE/TABLE — inklusive kommagetrennter Listen mit
Aliassen. JEDE referenzierte Tabelle muss freigegeben sein, nicht irgendeine.
Ohne Whitelist wird nichts durchgelassen. Die MongoDB-Pruefung vergleicht den
Collection-Namen jetzt exakt statt per Teilzeichenkette.

178 Tests gruen (91 Core, 87 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:49:17 +02:00

362 lines
16 KiB
C#

using System.Net.Http.Json;
using System.Text.Json;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Tools.DirectAPI;
public sealed class DirectApiTool : IAgentTool
{
public string Name => "DirectAPI";
public string Description => """
Ruft Echtzeit-Finanzdaten von verifizierten APIs ab.
Alle Antworten enthalten fetchedAt und dataAsOf Timestamps.
Aktionen: quote, history, crypto, forex, search
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",
"required": ["action", "symbol"],
"properties": {
"action": {
"type": "string",
"enum": ["quote", "history", "crypto", "forex", "search"],
"description": "quote=aktueller Kurs, history=Kursverlauf, crypto=Krypto, forex=Wechselkurs, search=Symbol suchen"
},
"symbol": { "type": "string", "description": "z.B. NVDA, BTC, EUR/USD" },
"provider": { "type": "string", "description": "optional: twelvedata|alphavantage|massive|coingecko|yahoo" },
"interval": { "type": "string", "description": "für history: 1min|5min|1h|1day" },
"outputsize":{ "type": "integer","description": "für history: Anzahl Datenpunkte, max 500" }
}
}
""").RootElement.Clone();
public async Task<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,
CancellationToken ct)
{
try
{
var action = input.GetProperty("action").GetString()!;
var symbol = input.GetProperty("symbol").GetString()!;
// Config laden
IReadOnlyDictionary<string, object?>? config = context.ToolConfig.TryGetValue("DirectAPI", out var c) && c is JsonElement je
? JsonSerializer.Deserialize<Dictionary<string, object?>>(je.GetRawText())
: context.ToolConfig;
if (config == null) return ToolResult.Fail("DirectAPI Konfiguration fehlt.");
var providers = config.GetValueOrDefault("providers") as JsonElement?;
var defaultProvider = config.GetValueOrDefault("defaultProvider")?.ToString() ?? "twelvedata";
var provider = input.TryGetProperty("provider", out var p) ? p.GetString() : defaultProvider;
return action switch
{
"quote" => await FetchQuoteAsync(symbol, provider!, providers, context, ct),
"history" => await FetchHistoryAsync(symbol, input, provider!, providers, context, ct),
"crypto" => await FetchCryptoAsync(symbol, providers, context, ct),
"forex" => await FetchForexAsync(symbol, provider!, providers, context, ct),
"search" => await SearchSymbolAsync(symbol, provider!, providers, context, ct),
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
};
}
catch (Exception ex)
{
context.Logger.LogError(ex, "Fehler in DirectAPI");
return ToolResult.Fail($"Fehler: {ex.Message}");
}
}
private async Task<ToolResult> FetchQuoteAsync(string symbol, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct)
{
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("Cache-Control", "no-cache");
var fetchedAt = DateTime.UtcNow;
if (provider == "twelvedata")
{
var apiKey = GetApiKey(providers, "twelvedata");
var url = $"https://api.twelvedata.com/quote?symbol={symbol}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (json.Value.TryGetProperty("code", out var code) && code.GetInt32() != 200)
return ToolResult.Fail(json.Value.GetProperty("message").GetString() ?? "API Fehler");
DateTime? dataAsOf = null;
if (json.Value.TryGetProperty("timestamp", out var ts))
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
return CreateSuccessResult(fetchedAt, dataAsOf, url, json.Value);
}
else if (provider == "alphavantage")
{
var apiKey = GetApiKey(providers, "alphavantage");
var url = $"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol={symbol}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (json.Value.TryGetProperty("Note", out var note))
return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}");
if (json.Value.TryGetProperty("Error Message", out var err))
return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}");
return CreateSuccessResult(fetchedAt, fetchedAt, url, json.Value);
}
else if (provider == "massive")
{
var apiKey = GetApiKey(providers, "massive");
var url = $"https://api.massive.com/v3/snapshot?ticker={symbol}";
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
return CreateSuccessResult(fetchedAt, fetchedAt, url, json.Value);
}
else if (provider == "yahoo")
{
var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1m&range=1d";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!json.Value.TryGetProperty("chart", out var chartProp) ||
!chartProp.TryGetProperty("result", out var resultArr) ||
resultArr.GetArrayLength() == 0)
return ToolResult.Fail($"Yahoo Finance: Keine Daten für '{symbol}' erhalten.");
var chart = resultArr[0];
var meta = chart.GetProperty("meta");
DateTime? dataAsOf = null;
if (meta.TryGetProperty("regularMarketTime", out var ts))
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
return CreateSuccessResult(fetchedAt, dataAsOf, url, chart);
}
return ToolResult.Fail($"Provider '{provider}' wird für 'quote' noch nicht unterstützt.");
}
private async Task<ToolResult> FetchHistoryAsync(string symbol, JsonElement input, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct)
{
var interval = input.TryGetProperty("interval", out var i) ? i.GetString() : "1day";
var outputSize = input.TryGetProperty("outputsize", out var o) ? o.GetInt32() : 30;
using var http = new HttpClient();
var fetchedAt = DateTime.UtcNow;
if (provider == "twelvedata")
{
var apiKey = GetApiKey(providers, "twelvedata");
var url = $"https://api.twelvedata.com/time_series?symbol={symbol}&interval={interval}&outputsize={outputSize}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (json.Value.TryGetProperty("status", out var status) && status.GetString() != "ok")
return ToolResult.Fail(json.Value.GetProperty("message").GetString() ?? "API Fehler");
return CreateSuccessResult(fetchedAt, null, url, json.Value);
}
else if (provider == "alphavantage")
{
var apiKey = GetApiKey(providers, "alphavantage");
var function_ = interval switch
{
"1min" or "5min" or "15min" or "30min" or "1h" => "TIME_SERIES_INTRADAY",
_ => "TIME_SERIES_DAILY"
};
var avInterval = interval switch
{
"1min" => "1min", "5min" => "5min", "15min" => "15min", "30min" => "30min", "1h" => "60min",
_ => ""
};
var urlBase = $"https://www.alphavantage.co/query?function={function_}&symbol={symbol}&apikey={apiKey}&outputsize=compact";
if (!string.IsNullOrEmpty(avInterval)) urlBase += $"&interval={avInterval}";
var (json, error) = await SafeGetJsonAsync(http, urlBase, ct);
if (error != null) return error;
if (json.Value.TryGetProperty("Note", out var note))
return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}");
if (json.Value.TryGetProperty("Error Message", out var err))
return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}");
return CreateSuccessResult(fetchedAt, null, urlBase, json.Value);
}
else if (provider == "massive")
{
var apiKey = GetApiKey(providers, "massive");
var timespan = interval switch
{
"1min" => "minute", "5min" => "minute", "15min" => "minute", "1h" => "hour", "1day" => "day",
_ => "day"
};
var multiplier = interval switch
{
"5min" => 5, "15min" => 15, _ => 1
};
var from = DateTime.UtcNow.AddDays(-outputSize).ToString("yyyy-MM-dd");
var to = DateTime.UtcNow.ToString("yyyy-MM-dd");
var url = $"https://api.massive.com/v2/aggs/ticker/{symbol}/range/{multiplier}/{timespan}/{from}/{to}?limit={outputSize}";
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
return CreateSuccessResult(fetchedAt, null, url, json.Value);
}
else if (provider == "yahoo")
{
var yahooInterval = interval switch
{
"1min" => "1m", "5min" => "5m", "15min" => "15m", "1h" => "1h", "1day" => "1d",
_ => "1d"
};
var range = interval is "1min" or "5min" or "15min" ? "1d" : $"{outputSize}d";
var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval={yahooInterval}&range={range}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!json.Value.TryGetProperty("chart", out var chartProp) ||
!chartProp.TryGetProperty("result", out var resultArr) ||
resultArr.GetArrayLength() == 0)
return ToolResult.Fail($"Yahoo Finance: Keine Historien-Daten für '{symbol}' erhalten.");
var chart = resultArr[0];
return CreateSuccessResult(fetchedAt, null, url, chart);
}
return ToolResult.Fail($"Provider '{provider}' wird für 'history' noch nicht unterstützt.");
}
private async Task<ToolResult> FetchCryptoAsync(string symbol, JsonElement? providers, AgentToolContext context, CancellationToken ct)
{
using var http = new HttpClient();
var fetchedAt = DateTime.UtcNow;
var url = $"https://api.coingecko.com/api/v3/simple/price?ids={symbol.ToLowerInvariant()}&vs_currencies=usd&include_last_updated_at=true";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
if (!json.Value.TryGetProperty(symbol.ToLowerInvariant(), out var data))
return ToolResult.Fail($"Symbol '{symbol}' nicht gefunden.");
DateTime? dataAsOf = null;
if (data.TryGetProperty("last_updated_at", out var ts))
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
return CreateSuccessResult(fetchedAt, dataAsOf, url, data);
}
private async Task<ToolResult> FetchForexAsync(string symbol, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct)
{
if (provider != "twelvedata") return ToolResult.Fail("Forex wird aktuell nur über twelvedata unterstützt.");
var apiKey = GetApiKey(providers, "twelvedata");
using var http = new HttpClient();
var fetchedAt = DateTime.UtcNow;
var url = $"https://api.twelvedata.com/exchange_rate?symbol={symbol}&apikey={apiKey}";
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
DateTime? dataAsOf = null;
if (json.Value.TryGetProperty("timestamp", out var ts))
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
return CreateSuccessResult(fetchedAt, dataAsOf, url, json.Value);
}
private async Task<ToolResult> SearchSymbolAsync(string symbol, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct)
{
if (provider != "twelvedata") return ToolResult.Fail("Search wird aktuell nur über twelvedata unterstützt.");
var apiKey = GetApiKey(providers, "twelvedata");
using var http = new HttpClient();
var fetchedAt = DateTime.UtcNow;
var url = $"https://api.twelvedata.com/symbol_search?symbol={symbol}"; // search often doesn't need key or uses same
var (json, error) = await SafeGetJsonAsync(http, url, ct);
if (error != null) return error;
return CreateSuccessResult(fetchedAt, null, url, json.Value);
}
/// <summary>
/// Sichere JSON-Antwort von einer API abrufen.
/// Prüft HTTP-Status und ob die Antwort tatsächlich JSON ist, bevor geparst wird.
/// </summary>
private static async Task<(JsonElement? json, ToolResult? error)> SafeGetJsonAsync(
HttpClient http, string url, CancellationToken ct)
{
// Auch Fehlermeldungen gehen an das Modell — der Schlüssel darf darin nicht auftauchen.
var safeUrl = UrlSanitizer.Sanitize(url);
HttpResponseMessage response;
try
{
response = await http.GetAsync(url, ct);
}
catch (HttpRequestException ex)
{
return (null, ToolResult.Fail($"API nicht erreichbar: {safeUrl} → {ex.Message}"));
}
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
$"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {safeUrl}\nAntwort: {preview}"));
}
// Prüfen ob die Antwort überhaupt JSON ist
var trimmed = body.TrimStart();
if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '['))
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
$"API hat kein JSON zurückgegeben ({safeUrl}). Antwort: {preview}"));
}
try
{
var json = JsonDocument.Parse(body).RootElement.Clone();
return (json, null);
}
catch (JsonException ex)
{
var preview = body.Length > 300 ? body[..300] + "…" : body;
return (null, ToolResult.Fail(
$"Ungültiges JSON von {safeUrl}: {ex.Message}\nAntwort: {preview}"));
}
}
private string? GetApiKey(JsonElement? providers, string name)
{
if (providers == null) return null;
if (providers.Value.TryGetProperty(name, out var p) && p.TryGetProperty("apiKey", out var k))
return k.GetString();
return null;
}
private ToolResult CreateSuccessResult(DateTime fetchedAt, DateTime? dataAsOf, string source, object data)
{
var result = new
{
fetchedAt = fetchedAt,
dataAsOf = dataAsOf,
// Ohne Bereinigung ginge der API-Schlüssel im Query-String an das Modell,
// in den persistierten Kontext und in die Logs.
source = UrlSanitizer.Sanitize(source),
data = data
};
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
}
}