From 7b16474a3347c47f5e2b994e3fe267b91da7f048 Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 6 Sep 2026 20:17:02 +0200 Subject: [PATCH] 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 --- src/IBKRTrader.Core/Orders/OrderManager.cs | 10 ++- src/IBKRTrader.Core/Settings/AppSettings.cs | 5 +- src/IBKRTrader.Hosting/Web/AuthService.cs | 10 ++- .../Web/Endpoints/CoreEndpoints.cs | 62 ++++++++++++++++--- .../wwwroot/js/views/logs.js | 5 +- .../IBKRTrader.Tests/Web/AuthServiceTests.cs | 34 ++++++++-- 6 files changed, 106 insertions(+), 20 deletions(-) diff --git a/src/IBKRTrader.Core/Orders/OrderManager.cs b/src/IBKRTrader.Core/Orders/OrderManager.cs index 17aa3e9..e114d29 100644 --- a/src/IBKRTrader.Core/Orders/OrderManager.cs +++ b/src/IBKRTrader.Core/Orders/OrderManager.cs @@ -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) { diff --git a/src/IBKRTrader.Core/Settings/AppSettings.cs b/src/IBKRTrader.Core/Settings/AppSettings.cs index bb2c946..56fe9f7 100644 --- a/src/IBKRTrader.Core/Settings/AppSettings.cs +++ b/src/IBKRTrader.Core/Settings/AppSettings.cs @@ -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")] diff --git a/src/IBKRTrader.Hosting/Web/AuthService.cs b/src/IBKRTrader.Hosting/Web/AuthService.cs index 7e9dcd3..504d754 100644 --- a/src/IBKRTrader.Hosting/Web/AuthService.cs +++ b/src/IBKRTrader.Hosting/Web/AuthService.cs @@ -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; } diff --git a/src/IBKRTrader.Hosting/Web/Endpoints/CoreEndpoints.cs b/src/IBKRTrader.Hosting/Web/Endpoints/CoreEndpoints.cs index 832a855..8758a0e 100644 --- a/src/IBKRTrader.Hosting/Web/Endpoints/CoreEndpoints.cs +++ b/src/IBKRTrader.Hosting/Web/Endpoints/CoreEndpoints.cs @@ -18,6 +18,50 @@ namespace IBKRTrader.Hosting.Web.Endpoints; /// public static class CoreEndpoints { + /// + /// Liest einen Instrumentenschlüssel, wie die Oberfläche ihn anzeigt – "SAP:EUR" ebenso wie + /// "AAPL|20260918|C|150". + /// + /// Nicht ForStock. 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. + /// + 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; + } + + /// Liest einen Buchnamen. BookId.Parse wirft bei Unsinn – hier wird daraus eine Antwort. + 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(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) => diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/logs.js b/src/IBKRTrader.Hosting/wwwroot/js/views/logs.js index 9ef4e8c..0c0afb9 100644 --- a/src/IBKRTrader.Hosting/wwwroot/js/views/logs.js +++ b/src/IBKRTrader.Hosting/wwwroot/js/views/logs.js @@ -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) …'); diff --git a/tests/IBKRTrader.Tests/Web/AuthServiceTests.cs b/tests/IBKRTrader.Tests/Web/AuthServiceTests.cs index 160d7ee..8959044 100644 --- a/tests/IBKRTrader.Tests/Web/AuthServiceTests.cs +++ b/tests/IBKRTrader.Tests/Web/AuthServiceTests.cs @@ -83,19 +83,45 @@ public class AuthServiceTests sut.IsAuthenticated(ctx).Should().BeTrue(); } + /// + /// Der Token im Query-String gilt nur für den SSE-Strom. EventSource kann keine + /// Kopfzeilen setzen – das ist der einzige Grund, aus dem es diesen Weg gibt. + /// [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(); } + /// + /// Ü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. + /// + [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() {