Server 2.10.4: HTTPS for the web panel and hostile client hardening

- Panel over HTTPS on the game port (TLS handshake detected like HTTP); self-signed
  certificate in data/panel_cert.pfx (fingerprint in the log) or an own PFX
  (PanelCertificatePath); remote HTTP is redirected to HTTPS (not locally / behind
  a proxy); cookie Secure over HTTPS; PanelHttps=false turns it off
- Panel login: also locked per user name (16 failures / 15 min), failure lists
  bounded, user names in the log without control characters
- panel_users.json, panel_sessions.json, server.json, certificate: owner-only on Linux
- Players: names/chat without control or direction characters (no fake log lines),
  no '<' '>' in names; '* ' system-style chat, msco.* messages (time/weather) and
  MSCO_INSTALL only from admins; NaN/absurd money changes dropped (could break the
  shared money for good)
- Extra headers: Permissions-Policy, Cross-Origin-Opener-Policy
- 11 new tests (165 pass)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:38:54 +02:00
co-authored by Claude Opus 5.5
parent d946c95198
commit 4cc71aa079
10 changed files with 315 additions and 20 deletions
+81 -10
View File
@@ -52,6 +52,10 @@ public sealed class AdminPanel
public string SetupCode { get; private set; }
/// <summary>Interner Port, wenn das Panel den Spiel-Port mitbenutzt.</summary>
public int InternalPort { get; private set; }
/// <summary>Interner HTTPS-Port (gleicher Port wie das Spiel: TLS-Verbindungen landen hier), 0 = kein HTTPS.</summary>
public int InternalTlsPort { get; private set; }
/// <summary>SHA-256-Fingerabdruck des HTTPS-Zertifikats (zum Vergleichen im Browser), null = kein HTTPS.</summary>
public string CertificateFingerprint { get; private set; }
public bool SharesGamePort { get; private set; }
AdminPanel(ServerHost host, ServerConfig config, string dataDir)
@@ -85,22 +89,36 @@ public sealed class AdminPanel
SharesGamePort = config.PanelPort <= 0 || config.PanelPort == config.Port;
IPAddress bind;
int port;
// HTTPS: auf dem Spiel-Port immer (eigener interner Port), bei eigenem Panel-Port nur mit PanelHttps
System.Security.Cryptography.X509Certificates.X509Certificate2 cert = null;
bool ownCert = !string.IsNullOrWhiteSpace(config.PanelCertificatePath);
if (SharesGamePort || (config.PanelHttps && ownCert))
{
try { cert = PanelTls.Load(config, dataDir, GameServer.Log); }
catch (Exception e) { GameServer.Log("Web panel: HTTPS not available (" + e.Message + ")"); }
}
CertificateFingerprint = cert != null ? PanelTls.Fingerprint(cert) : null;
InternalTlsPort = 0;
if (SharesGamePort)
{
bind = IPAddress.Loopback;
port = InternalPort = FreeLoopbackPort();
if (cert != null) InternalTlsPort = FreeLoopbackPort();
}
else
{
bind = ParseBind(config.PanelBindAddress);
port = config.PanelPort;
}
// Eigener Panel-Port: nur mit eigenem Zertifikat HTTPS (meist steht dort ein Reverse-Proxy, der HTTP erwartet)
bool ownPortHttps = !SharesGamePort && config.PanelHttps && ownCert && cert != null;
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { ContentRootPath = AppContext.BaseDirectory });
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(k =>
{
k.Listen(bind, port);
k.Listen(bind, port, o => { if (ownPortHttps) o.UseHttps(cert); });
if (InternalTlsPort > 0) k.Listen(IPAddress.Loopback, InternalTlsPort, o => o.UseHttps(cert));
k.Limits.MaxRequestBodySize = 64 * 1024;
k.AddServerHeader = false;
});
@@ -139,10 +157,28 @@ public sealed class AdminPanel
app.UsePathBase(basePath);
}
// Von außen per HTTP auf dem Spiel-Port: auf HTTPS umleiten, damit Passwort und Sitzung verschlüsselt sind
// (lokal/über einen eigenen Proxy nicht - der Proxy macht HTTPS selbst)
app.Use(async (ctx, next) =>
{
var remote = ctx.Connection.RemoteIpAddress;
if (InternalTlsPort > 0 && config.PanelHttps && !config.PanelTrustProxy && !ctx.Request.IsHttps
&& remote != null && !IPAddress.IsLoopback(remote))
{
string host = ctx.Request.Host.HasValue ? ctx.Request.Host.Value : "localhost";
ctx.Response.StatusCode = HttpMethods.IsGet(ctx.Request.Method) || HttpMethods.IsHead(ctx.Request.Method) ? 301 : 308;
ctx.Response.Headers.Location = "https://" + host + ctx.Request.PathBase + ctx.Request.Path + ctx.Request.QueryString;
return;
}
await next();
});
// Sicherheits-Header
app.Use(async (ctx, next) =>
{
var h = ctx.Response.Headers;
h["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()";
h["Cross-Origin-Opener-Policy"] = "same-origin";
h["X-Content-Type-Options"] = "nosniff";
h["X-Frame-Options"] = "DENY";
h["Referrer-Policy"] = "no-referrer";
@@ -157,10 +193,11 @@ public sealed class AdminPanel
app.StartAsync().GetAwaiter().GetResult();
if (users.Empty && SetupCode == null) CreateSetupCode();
string scheme = InternalTlsPort > 0 || ownPortHttps ? "https" : "http";
if (SharesGamePort)
GameServer.Log($"Web panel: http://<server>:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
GameServer.Log($"Web panel: {scheme}://<server>:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
else
GameServer.Log($"Web panel: http://{(bind.Equals(IPAddress.Any) ? "<server>" : bind.ToString())}:{port}{basePath}/");
GameServer.Log($"Web panel: {scheme}://{(bind.Equals(IPAddress.Any) ? "<server>" : bind.ToString())}:{port}{basePath}/");
}
/// <summary>Neu gelesene Einstellungen (der Host liest server.json bei jedem Start neu).</summary>
@@ -248,14 +285,15 @@ public sealed class AdminPanel
}
/// <summary>Browser-Verbindung vom Spiel-Port an das intern laufende Panel weiterreichen (in beide Richtungen).</summary>
public async Task RelayAsync(TcpClient browser)
public async Task RelayAsync(TcpClient browser, bool tls = false)
{
var real = (browser.Client.RemoteEndPoint as IPEndPoint)?.Address;
using var panel = new TcpClient { NoDelay = true };
int localPort = 0;
try
{
await panel.ConnectAsync(IPAddress.Loopback, InternalPort);
if (tls && InternalTlsPort == 0) return; // kein Zertifikat
await panel.ConnectAsync(IPAddress.Loopback, tls ? InternalTlsPort : InternalPort);
localPort = ((IPEndPoint)panel.Client.LocalEndPoint).Port;
if (real != null) relayed[localPort] = real.IsIPv4MappedToIPv6 ? real.MapToIPv4() : real;
using var a = browser.GetStream();
@@ -341,14 +379,21 @@ public sealed class AdminPanel
if (!CsrfOk(ctx)) return Forbidden("Missing header");
if (RateLimited(ctx)) return Error(429, "Too many failed logins - wait a few minutes");
var body = await Body(ctx);
var user = users.Verify(Str(body, "username").Trim(), Str(body, "password"));
string name = Str(body, "username").Trim();
// Auch pro Benutzer sperren: sonst ließe sich ein Passwort über viele IPs verteilt durchprobieren
string userKey = "user:" + name.ToLowerInvariant();
if (RateLimited(userKey, MaxLoginFailures * 2, TimeSpan.FromMinutes(15)))
return Error(429, "Too many failed logins for this user - wait 15 minutes");
var user = users.Verify(name, Str(body, "password"));
if (user == null)
{
RecordFailure(ctx);
GameServer.Log($"[panel] Failed login for '{Str(body, "username").Trim()}' from {Ip(ctx)}");
RecordFailure(userKey);
GameServer.Log($"[panel] Failed login for '{LogSafe(name)}' from {Ip(ctx)}");
return Error(401, "Wrong username or password");
}
failures.TryRemove(Ip(ctx), out _);
failures.TryRemove(userKey, out _);
StartSession(ctx, user.Name);
GameServer.Log($"[panel] {user.Name} logged in ({Ip(ctx)})");
return Results.Json(new { ok = true });
@@ -903,6 +948,7 @@ public sealed class AdminPanel
{
string tmp = SessionsPath + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(sessions.ToDictionary(kv => kv.Key, kv => kv.Value), SessionJson));
PanelTls.Restrict(tmp);
File.Move(tmp, SessionsPath, true);
}
catch { }
@@ -930,10 +976,35 @@ public sealed class AdminPanel
}
}
void RecordFailure(HttpContext ctx)
void RecordFailure(HttpContext ctx) => RecordFailure(Ip(ctx));
void RecordFailure(string key)
{
var list = failures.GetOrAdd(Ip(ctx), _ => new List<DateTime>());
lock (list) list.Add(DateTime.UtcNow);
if (failures.Count > 20_000) failures.Clear(); // Speicher begrenzen (viele verschiedene IPs/Namen)
var list = failures.GetOrAdd(key, _ => new List<DateTime>());
lock (list)
{
list.Add(DateTime.UtcNow);
if (list.Count > 100) list.RemoveRange(0, list.Count - 100);
}
}
bool RateLimited(string key, int max, TimeSpan window)
{
if (!failures.TryGetValue(key, out var list)) return false;
lock (list)
{
list.RemoveAll(t => DateTime.UtcNow - t > window);
return list.Count >= max;
}
}
/// <summary>Text aus einer Anfrage fürs Log: ohne Zeilenumbrüche/Steuerzeichen (keine gefälschten Log-Zeilen), gekürzt.</summary>
static string LogSafe(string s)
{
if (string.IsNullOrEmpty(s)) return "";
var chars = s.Take(64).Select(c => char.IsControl(c) ? '?' : c).ToArray();
return new string(chars);
}
static async Task<JsonElement> Body(HttpContext ctx)