diff --git a/src/PolyTrader.Web/Endpoints/SettingsEndpoints.cs b/src/PolyTrader.Web/Endpoints/SettingsEndpoints.cs index 695dccc..c451905 100644 --- a/src/PolyTrader.Web/Endpoints/SettingsEndpoints.cs +++ b/src/PolyTrader.Web/Endpoints/SettingsEndpoints.cs @@ -7,9 +7,22 @@ using PolyTraderSharp.Services; namespace PolyTrader.Web.Endpoints { + /// + /// Zugangseinstellungen des WebUI - nur lesend (#562). + /// + /// Entscheidung Richard, 13.09.2026: Geheimnisse verwalten wir ausschliesslich im Desktop. Bis + /// dahin lieferte GET /api/settings/webui das Zugangs-Token im Klartext aus, und ueber das WebUI + /// liessen sich Token setzen, ein neues erzeugen und LocalOnly abschalten. Wer das Token hatte, + /// konnte damit das WebUI ins Netz oeffnen (unverschluesseltes HTTP), sich den Zugang verlegen oder + /// sich selbst aussperren. + /// + /// Port, LocalOnly, Enabled und das Token werden jetzt nur noch im Desktop unter + /// Einstellungen -> WebUI gepflegt. Das WebUI zeigt die Werte an, vom Token nur, ob es gesetzt ist. + /// public static class SettingsEndpoints { - public record UpdateWebUiSettingsRequest(bool Enabled, int Port, bool LocalOnly, string? AuthToken); + internal const string DesktopOnlyMessage = + "Zugang, Port und Token des WebUI werden ausschließlich im Desktop verwaltet (Einstellungen → WebUI)."; public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder routes) { @@ -24,51 +37,23 @@ namespace PolyTrader.Web.Endpoints port = opt.Port, localOnly = opt.LocalOnly, hasToken = !string.IsNullOrWhiteSpace(opt.AuthToken), - token = opt.AuthToken + managedIn = "desktop" }); }); - group.MapPost("/webui", (UpdateWebUiSettingsRequest req, IOptions optionsProvider, TerminalLogger logger) => - { - var opt = optionsProvider.Value; - opt.Enabled = req.Enabled; - opt.Port = req.Port > 0 && req.Port <= 65535 ? req.Port : 6001; - opt.LocalOnly = req.LocalOnly; - if (req.AuthToken != null) - { - opt.AuthToken = req.AuthToken.Trim(); - } - - WebUiConfigManager.SaveToLocalSettings(opt); - logger.Info($"🌐 WebUI: Einstellungen gespeichert (Port {opt.Port}, LocalOnly: {opt.LocalOnly})."); - - return Results.Ok(new - { - success = true, - enabled = opt.Enabled, - port = opt.Port, - localOnly = opt.LocalOnly, - hasToken = !string.IsNullOrWhiteSpace(opt.AuthToken) - }); - }); - - group.MapPost("/webui/generate-token", (IOptions optionsProvider, TerminalLogger logger) => - { - var opt = optionsProvider.Value; - string newToken = WebUiConfigManager.GenerateSecureToken(); - opt.AuthToken = newToken; - - WebUiConfigManager.SaveToLocalSettings(opt); - logger.Info("🔐 WebUI: Neues Zugangs-Token generiert und in appsettings.Local.json gespeichert."); - - return Results.Ok(new - { - success = true, - token = newToken - }); - }); + // Die frueheren Schreibwege bleiben als ausdrueckliche Ablehnung stehen, statt zu verschwinden: + // Ohne eigene Route faengt die Fallback-Route (index.html) den POST ab und antwortet mit 200 - + // ein altes Skript hielte das fuer einen Erfolg. + group.MapPost("/webui", RejectDesktopOnly); + group.MapPost("/webui/generate-token", RejectDesktopOnly); return routes; } + + private static IResult RejectDesktopOnly(HttpContext context, TerminalLogger logger) + { + logger.Warning($"🌐 WebUI: Änderung der Zugangseinstellungen über {context.Request.Path} abgelehnt – nur im Desktop möglich."); + return Results.Json(new { error = DesktopOnlyMessage }, statusCode: StatusCodes.Status403Forbidden); + } } } diff --git a/src/PolyTrader.Web/wwwroot/css/app.css b/src/PolyTrader.Web/wwwroot/css/app.css index 9b782ee..8296835 100644 --- a/src/PolyTrader.Web/wwwroot/css/app.css +++ b/src/PolyTrader.Web/wwwroot/css/app.css @@ -1162,3 +1162,22 @@ tr:hover td { content: "⚠️"; margin-right: 6px; } + +/* Nur lesend angezeigte Einstellungen (#562) */ +.settings-readonly { + display: grid; + grid-template-columns: max-content 1fr; + gap: 10px 28px; + margin: 0; +} + +.settings-readonly dt { + color: var(--muted); + font-size: 13px; +} + +.settings-readonly dd { + margin: 0; + font-size: 14px; + font-weight: 500; +} diff --git a/src/PolyTrader.Web/wwwroot/index.html b/src/PolyTrader.Web/wwwroot/index.html index a35cad7..e31700f 100644 --- a/src/PolyTrader.Web/wwwroot/index.html +++ b/src/PolyTrader.Web/wwwroot/index.html @@ -1151,46 +1151,27 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
-

WebGUI Anwendungseinstellungen

+

Einstellungen

+ +
-
-
- -
Steuert, ob Kestrel beim Programmstart hochfährt.
-
- -
- - -
Standard ist 6001. Änderung wird nach dem nächsten Neustart aktiv.
-
- -
- -
Wenn deaktiviert, ist das WebUI über alle Netzwerkschnittstellen (LAN/VPN) erreichbar.
-
- -
- -
- - - -
-
Wird in appsettings.Local.json gespeichert. Bei leerem Token ist das WebUI gesperrt.
-
- -
- - -
-
+
Zugang zum WebUI
+
+ Zugang, Port und Token werden ausschließlich im Desktop verwaltet + (Einstellungen → WebUI). Hier stehen sie nur zur Ansicht. +
+
+
WebUI-Server
+
…
+
Port
+
…
+
Erreichbar
+
…
+
Zugangs-Token
+
…
+
diff --git a/src/PolyTrader.Web/wwwroot/js/app.js b/src/PolyTrader.Web/wwwroot/js/app.js index c0c822b..eb05d43 100644 --- a/src/PolyTrader.Web/wwwroot/js/app.js +++ b/src/PolyTrader.Web/wwwroot/js/app.js @@ -585,54 +585,17 @@ async function loadSettings() { if (!res.ok) return; const opt = await res.json(); - document.getElementById('cfgEnabled').checked = opt.enabled; - document.getElementById('cfgPort').value = opt.port; - document.getElementById('cfgLocalOnly').checked = opt.localOnly; - document.getElementById('cfgToken').value = opt.token || ''; + document.getElementById('cfgEnabled').textContent = opt.enabled ? 'aktiv' : 'deaktiviert'; + document.getElementById('cfgPort').textContent = opt.port; + document.getElementById('cfgBind').textContent = opt.localOnly + ? 'nur lokal (127.0.0.1)' + : 'im Netzwerk – alle Schnittstellen, unverschlüsseltes HTTP'; + document.getElementById('cfgTokenStatus').textContent = opt.hasToken + ? 'gesetzt' + : 'nicht gesetzt – das WebUI ist gesperrt'; } catch (e) {} } -async function saveSettings(e) { - e.preventDefault(); - const enabled = document.getElementById('cfgEnabled').checked; - const port = parseInt(document.getElementById('cfgPort').value, 10); - const localOnly = document.getElementById('cfgLocalOnly').checked; - const token = document.getElementById('cfgToken').value.trim(); - - try { - const res = await fetch('/api/settings/webui', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled, port, localOnly, authToken: token }) - }); - if (res.ok) { - const msg = document.getElementById('settingsStatusMsg'); - msg.style.display = 'inline'; - setTimeout(() => { msg.style.display = 'none'; }, 3000); - } - } catch (e) {} -} - -async function generateNewToken() { - if (!confirm('Neues Zugangs-Token generieren? Alle bestehenden Sessions müssen sich danach neu anmelden.')) return; - try { - const res = await fetch('/api/settings/webui/generate-token', { method: 'POST' }); - if (res.ok) { - const data = await res.json(); - document.getElementById('cfgToken').value = data.token; - alert('Neues Token generiert und in appsettings.Local.json gespeichert:\n\n' + data.token); - } - } catch (e) {} -} - -function copyToken() { - const input = document.getElementById('cfgToken'); - if (!input.value) return; - navigator.clipboard.writeText(input.value).then(() => { - alert('Token in Zwischenablage kopiert!'); - }); -} - // Auth async function logout() { try { diff --git a/tests/PolyTrader.Tests/WebUi/WebUiSecretsTests.cs b/tests/PolyTrader.Tests/WebUi/WebUiSecretsTests.cs new file mode 100644 index 0000000..2d767ad --- /dev/null +++ b/tests/PolyTrader.Tests/WebUi/WebUiSecretsTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PolyTraderSharp.Models; +using Xunit; + +namespace PolyTrader.Tests.WebUi +{ + /// + /// Geheimnisse verlassen den Server nie ueber das WebUI (#562). + /// + /// Entscheidung Richard, 13.09.2026: Geheimnisse werden ausschliesslich im Desktop verwaltet. + /// Dieser Test haelt das fest, ohne sich auf eine Liste von Routen zu verlassen: Er zaehlt alle + /// GET-Routen selbst auf und prueft jede Antwort gegen eindeutige Kanarienwerte. Eine neue Route, + /// die versehentlich ein Konto oder die Server-Einstellungen serialisiert, faellt damit auf, ohne + /// dass jemand an diesen Test denken muss. + /// + public class WebUiSecretsTests + { + private const string ApiKeyCanary = "CANARY-APIKEY-4b1f"; + private const string ApiSecretCanary = "CANARY-APISECRET-8c2e"; + private const string ApiPassphraseCanary = "CANARY-PASSPHRASE-1d7a"; + private const string PrivateKeyCanary = "CANARY-PRIVATEKEY-0x9f1e2d3c"; + private const string MullvadCanary = "CANARY-MULLVAD-5566"; + private const string AlchemyCanary = "CANARY-ALCHEMYKEY-7a7a"; + private const string WatchdogCanary = "CANARY-WATCHDOGTOKEN-3e3e"; + private const string LicenseCanary = "CANARY-LICENSE-2f2f"; + + private static readonly string[] Secrets = + { + ApiKeyCanary, ApiSecretCanary, ApiPassphraseCanary, PrivateKeyCanary, + MullvadCanary, AlchemyCanary, WatchdogCanary, LicenseCanary, + WebUiTestHost.Token + }; + + /// + /// Ein Live-Konto mit allen Schluesseln und Server-Einstellungen mit allen Geheimnissen. Die + /// Modul-Ablagen sind registriert, damit die Routen echten Code durchlaufen statt frueh + /// mit "nicht verfuegbar" zu antworten. + /// + private static async Task StartWithSecretsAsync() + { + var settings = new ServerSettings + { + MullvadAccount = MullvadCanary, + PolygonRpcUrl = "wss://polygon-mainnet.g.alchemy.com/v2/" + AlchemyCanary, + WatchdogToken = WatchdogCanary, + LicenseKey = LicenseCanary + }; + + var web = await WebUiTestHost.StartAsync(s => + { + WebUiTestHost.AddCopyTradingStore(s); + WebUiTestHost.AddResolutionFarmingStore(s); + s.AddSingleton(settings); + }); + + web.State.Accounts[1] = new AccountState + { + AccountId = 1, + Name = "Live-Konto", + WalletAddress = "0x00000000000000000000000000000000000000a1", + ApiKey = ApiKeyCanary, + ApiSecret = ApiSecretCanary, + ApiPassphrase = ApiPassphraseCanary, + PrivateKey = PrivateKeyCanary, + TotalBalance = 1000m, + AvailableBalance = 800m + }; + + return web; + } + + /// + /// Alle GET-Routen unter /api, mit eingesetzten Parametern. Ausgenommen ist nur der Live-Log-Stream: + /// Server-Sent Events enden nie. Er liefert dieselben Eintraege wie GET /api/logs, und die + /// Route ist abgedeckt. + /// + private static List ApiGetPaths(WebUiTestHost web) + { + var paths = new List(); + foreach (var endpoint in web.Get().Endpoints.OfType()) + { + string raw = endpoint.RoutePattern.RawText ?? string.Empty; + if (!raw.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) continue; + if (raw.Contains('*')) continue; + if (raw.EndsWith("/stream", StringComparison.OrdinalIgnoreCase)) continue; + + var methods = endpoint.Metadata.GetMetadata()?.HttpMethods; + if (methods == null || !methods.Contains("GET")) continue; + + paths.Add(Regex.Replace(raw, @"\{([^}:]+)(:([^}]+))?\}", + m => m.Groups[3].Value is "int" or "long" ? "1" : "x")); + } + return paths; + } + + [Fact] + public async Task Keine_GET_Route_liefert_ein_Geheimnis_aus() + { + await using var web = await StartWithSecretsAsync(); + + var paths = ApiGetPaths(web); + foreach (var path in paths) + { + using var res = await web.Client.GetAsync(path); + string body = await res.Content.ReadAsStringAsync(); + + foreach (var secret in Secrets) + { + Assert.False(body.Contains(secret, StringComparison.Ordinal), + $"GET {path} ({(int)res.StatusCode}) liefert ein Geheimnis aus: {secret}"); + } + } + + // Ohne Untergrenze bestuende der Test auch dann, wenn die Aufzaehlung leer bliebe. + Assert.True(paths.Count >= 25, $"Nur {paths.Count} GET-Routen gefunden: {string.Join(", ", paths)}"); + } + + [Fact] + public async Task Zugangseinstellungen_zeigen_vom_Token_nur_ob_es_gesetzt_ist() + { + await using var web = await WebUiTestHost.StartAsync(); + + var options = await web.GetJsonAsync("/api/settings/webui"); + + Assert.True(options.GetProperty("hasToken").GetBoolean()); + Assert.False(options.TryGetProperty("token", out _)); + Assert.Equal("desktop", options.GetProperty("managedIn").GetString()); + } + + [Fact] + public async Task Zugangseinstellungen_lassen_sich_ueber_das_WebUI_nicht_aendern() + { + await using var web = await WebUiTestHost.StartAsync(); + + var (saveStatus, saveBody) = await web.PostJsonAsync("/api/settings/webui", + new { enabled = true, port = 7000, localOnly = false, authToken = "selbst-gewaehlt" }); + var (generateStatus, generateBody) = await web.PostJsonAsync("/api/settings/webui/generate-token"); + + Assert.Equal(403, saveStatus); + Assert.Equal(403, generateStatus); + Assert.Contains("Desktop", saveBody.GetProperty("error").GetString()); + Assert.False(generateBody.TryGetProperty("token", out _)); + + // Nichts davon darf den laufenden Zustand beruehrt haben. + Assert.Equal(WebUiTestHost.Token, web.WebOptions.AuthToken); + Assert.True(web.WebOptions.LocalOnly); + Assert.Equal(6001, web.WebOptions.Port); + } + + [Fact] + public async Task Polymarket_Konten_sind_im_WebUI_nur_lesbar() + { + await using var web = await WebUiTestHost.StartAsync(); + + var accountRoutes = web.Get().Endpoints.OfType() + .Where(e => (e.RoutePattern.RawText ?? string.Empty) + .StartsWith("/api/accounts", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + Assert.NotEmpty(accountRoutes); + Assert.All(accountRoutes, e => + Assert.Equal(new[] { "GET" }, e.Metadata.GetMetadata()!.HttpMethods)); + } + } +}