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() {