Nachbesserungen am Web-UI: Optionsschluessel, Erreichbarkeit, Vorgaben

Vier Punkte aus der Durchsicht von f923721. Alles Uebrige traegt: Anmeldung
greift (unangemeldet 401 auf jedem /api-Pfad ausser status und login), alle
zwanzig Endpunkte antworten, SSE laeuft, die Oberflaeche zeigt echte Zahlen,
die Daemon-Schalter funktionieren weiter, und die Modul-Routen sitzen richtig
im Hosting-Projekt statt im Core.

1. Die Endpunkte /positions/lock und /positions/reassign deuteten den
   Instrumentenschluessel mit InstrumentRef.ForStock als Ticker. Bei einer Aktie
   ging das zufaellig gut - die Waehrung fiel still auf USD, der Schluessel kam
   trotzdem richtig heraus. Bei einem Optionskontrakt warf Normalize wegen des
   Trennzeichens "|" eine Ausnahme: HTTP 500 ohne Text, auf der Testmaschine
   reproduziert. Jetzt InstrumentRef.TryParse, und ein unlesbarer Schluessel
   ebenso wie ein unsinniger Buchname ergibt eine Antwort statt eines Absturzes.

2. OrderManager las UseTwsApi als Erreichbarkeitsnachweis. Das ist eine
   Einstellung, kein Zustand - sie steht auch dann auf true, wenn TWS nicht
   laeuft. Die Ansicht haette waehrend jeder Stoerung "Broker erreichbar, keine
   offenen Orders" gemeldet und jede Bindung als grundlos dargestellt. Genau
   davor warnt die Beschreibung an IBrokerOrderBook.GetOpenOrdersAsync.

3. AllowRemoteAccess stand als Vorgabe auf true: erster Start heisst 0.0.0.0,
   Klartext-HTTP, und der AuthToken ist anfangs leer. Ausnutzbar war das nicht
   (ohne Token faellt die Anmeldung zu, alles antwortet 401), aber die Vorgabe
   zeigte in die falsche Richtung. Jetzt lokal, mit Begruendung im Text.

4. Das Sitzungstoken stand im Query-String des SSE-Stroms. Unnoetig: EventSource
   schickt das HttpOnly-Cookie bei gleichem Ursprung von selbst. Die Oberflaeche
   nutzt den Weg nicht mehr, und AuthService nimmt ihn nur noch fuer
   /api/logs/stream an - ein Query-String landet in Logs, Verlauf und Referer.

Geminis Test auf den Query-Token bestand auf dem alten, breiten Verhalten. Er
ist durch zwei ersetzt, die die enge Regel in beide Richtungen festhalten.

499 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-06 20:17:02 +02:00
co-authored by Claude Opus 5
parent a94f94002f
commit 7b16474a33
6 changed files with 106 additions and 20 deletions
+7 -3
View File
@@ -75,9 +75,13 @@ public sealed class OrderManager
try
{
brokerOrders = await _orderBook.GetOpenOrdersAsync(ct).ConfigureAwait(false);
// Wenn der Adapter eine Antwort liefert (auch leer), aber keine Exception wirft,
// prüfen wir, ob die Broker-Verbindung grundsätzlich aktiv ist.
reachable = brokerOrders.Count > 0 || _settings.Settings.IBKR.UseTwsApi;
// UseTwsApi ist eine EINSTELLUNG, kein Zustand: Sie steht auch dann auf true, wenn TWS
// gerade nicht laeuft. Als Erreichbarkeitsnachweis gelesen, meldete die Ansicht waehrend
// jeder Stoerung "Broker erreichbar, keine offenen Orders" - und jede Bindung erschiene
// grundlos. Maßgeblich ist allein, dass tatsaechlich etwas kam; siehe die Warnung an
// IBrokerOrderBook.GetOpenOrdersAsync.
reachable = brokerOrders.Count > 0;
}
catch (Exception ex)
{
+3 -2
View File
@@ -163,8 +163,9 @@ public class WebserverSettings
[Category("Webserver")]
[DisplayName("Netzwerkzugriff erlauben")]
[Description("Wenn aktiviert, lauscht der Server auf allen Schnittstellen (0.0.0.0), sonst nur lokal (127.0.0.1)")]
public bool AllowRemoteAccess { get; set; } = true;
[Description("Wenn aktiviert, lauscht der Server auf allen Schnittstellen (0.0.0.0), sonst nur lokal (127.0.0.1). " +
"Vorgabe ist lokal: Der Server spricht Klartext-HTTP, der Token ginge im Netz mit.")]
public bool AllowRemoteAccess { get; set; }
[Category("Webserver")]
[DisplayName("Auth-Token")]
+8 -2
View File
@@ -146,8 +146,14 @@ public sealed class AuthService
if (ValidateSessionToken(cookieToken) || ValidateToken(cookieToken)) return true;
}
// 3. Query-Parameter ?token= (wichtig für SSE EventSource)
if (context.Request.Query.TryGetValue("token", out var queryToken))
// 3. Query-Parameter ?token= – NUR fuer den SSE-Strom.
//
// EventSource kann keine Kopfzeilen setzen; ein Client ohne Cookie haette sonst keinen Weg.
// Ueberall sonst ist der Query-String der falsche Ort fuer ein Geheimnis: Er landet in
// Server-Logs, im Browserverlauf und in Referer-Kopfzeilen. Die eigene Oberflaeche nutzt ihn
// gar nicht mehr - sie laesst das HttpOnly-Cookie mitlaufen.
if (context.Request.Path.StartsWithSegments("/api/logs/stream", StringComparison.OrdinalIgnoreCase) &&
context.Request.Query.TryGetValue("token", out var queryToken))
{
if (ValidateSessionToken(queryToken) || ValidateToken(queryToken)) return true;
}
@@ -18,6 +18,50 @@ namespace IBKRTrader.Hosting.Web.Endpoints;
/// </summary>
public static class CoreEndpoints
{
/// <summary>
/// Liest einen Instrumentenschlüssel, wie die Oberfläche ihn anzeigt – "SAP:EUR" ebenso wie
/// "AAPL|20260918|C|150".
///
/// <para><b>Nicht <c>ForStock</c>.</b> Das deutete die ganze Zeichenkette als Ticker: Bei einer
/// Aktie ging das zufällig gut (die Währung landete still auf USD), bei einem Optionskontrakt
/// warf es wegen des Trennzeichens eine Ausnahme – aus dem Web-UI ein HTTP 500 ohne Text.</para>
/// </summary>
private static bool TryReadInstrument(string? key, out InstrumentRef instrument, out string problem)
{
instrument = default;
if (string.IsNullOrWhiteSpace(key))
{
problem = "Kein Instrument angegeben.";
return false;
}
if (!InstrumentRef.TryParse(key, out instrument))
{
problem = $"\"{key}\" ist kein lesbarer Instrumentenschlüssel.";
return false;
}
problem = "";
return true;
}
/// <summary>Liest einen Buchnamen. <c>BookId.Parse</c> wirft bei Unsinn – hier wird daraus eine Antwort.</summary>
private static bool TryReadBook(string? name, out BookId book, out string problem)
{
book = default;
try
{
book = BookId.Parse(name ?? "");
problem = "";
return true;
}
catch (ArgumentException ex)
{
problem = ex.Message;
return false;
}
}
public static void Map(RouteGroupBuilder api)
{
// ─── Status & Auth ────────────────────────────────────────────────────────
@@ -154,23 +198,27 @@ public static class CoreEndpoints
api.MapPost("/positions/reassign", async (ReassignRequest req, PositionManager positions) =>
{
var instrument = InstrumentRef.ForStock(req.Instrument);
var fromBook = BookId.Parse(req.FromBook);
var toBook = BookId.Parse(req.ToBook);
if (!TryReadInstrument(req.Instrument, out var instrument, out var problem))
return Results.BadRequest(new { success = false, message = problem });
if (!TryReadBook(req.FromBook, out var fromBook, out problem) ||
!TryReadBook(req.ToBook, out var toBook, out problem))
return Results.BadRequest(new { success = false, message = problem });
var result = await positions.ReassignAsync(instrument, fromBook, toBook, req.Reason).ConfigureAwait(false);
var result = await positions.ReassignAsync(instrument, fromBook, toBook, req.Reason ?? "").ConfigureAwait(false);
return Results.Ok(new { success = result.Success, message = result.Message });
});
api.MapPost("/positions/lock", async (SetLockRequest req, PositionManager positions) =>
{
var instrument = InstrumentRef.ForStock(req.Instrument);
var book = BookId.Parse(req.Book);
if (!TryReadInstrument(req.Instrument, out var instrument, out var problem))
return Results.BadRequest(new { success = false, message = problem });
if (!TryReadBook(req.Book, out var book, out problem))
return Results.BadRequest(new { success = false, message = problem });
if (!Enum.TryParse<PositionLock>(req.Level, true, out var level))
return Results.BadRequest(new { success = false, message = "Ungültige Sperrstufe." });
await positions.SetLockAsync(book, instrument, level, req.Note).ConfigureAwait(false);
return Results.Ok(new { success = true, message = $"Sperre für {req.Instrument} gesetzt." });
return Results.Ok(new { success = true, message = $"Sperre für {instrument.Key} gesetzt." });
});
api.MapPost("/positions/rule", async (SetRuleRequest req, PositionManager positions) =>
@@ -60,8 +60,9 @@ export class LogsView {
this.eventSource.close();
}
const token = api.token;
const url = `/api/logs/stream${token ? `?token=${encodeURIComponent(token)}` : ''}`;
// Kein Token in der URL: EventSource sendet das HttpOnly-Cookie ibkr_session von selbst
// (gleicher Ursprung). Ein Token im Query-String landete in Server-Logs und im Verlauf.
const url = '/api/logs/stream';
this.setStatus('Verbinde mit Live-Log-Stream (SSE) …');
+30 -4
View File
@@ -83,19 +83,45 @@ public class AuthServiceTests
sut.IsAuthenticated(ctx).Should().BeTrue();
}
/// <summary>
/// Der Token im Query-String gilt <b>nur</b> für den SSE-Strom. EventSource kann keine
/// Kopfzeilen setzen – das ist der einzige Grund, aus dem es diesen Weg gibt.
/// </summary>
[Fact]
public void IsAuthenticated_RecognizesQueryToken()
public void IsAuthenticated_AcceptsQueryToken_OnTheLogStream()
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = "my-secret-key";
var sut = new AuthService(settings);
var sut = SutWithToken("my-secret-key");
var ctx = new Microsoft.AspNetCore.Http.DefaultHttpContext();
ctx.Request.Path = "/api/logs/stream";
ctx.Request.QueryString = new Microsoft.AspNetCore.Http.QueryString("?token=my-secret-key");
sut.IsAuthenticated(ctx).Should().BeTrue();
}
/// <summary>
/// Überall sonst zählt er nicht. Ein Query-String landet in Server-Logs, im Browserverlauf und
/// in Referer-Kopfzeilen; ein Geheimnis hat dort nichts zu suchen, wo ein anderer Weg offensteht.
/// </summary>
[Fact]
public void IsAuthenticated_IgnoresQueryToken_EverywhereElse()
{
var sut = SutWithToken("my-secret-key");
var ctx = new Microsoft.AspNetCore.Http.DefaultHttpContext();
ctx.Request.Path = "/api/positions";
ctx.Request.QueryString = new Microsoft.AspNetCore.Http.QueryString("?token=my-secret-key");
sut.IsAuthenticated(ctx).Should().BeFalse();
}
private static AuthService SutWithToken(string token)
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = token;
return new AuthService(settings);
}
[Fact]
public void IsAuthenticated_RejectsUnauthenticatedRequest()
{