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