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:
+81
-10
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user