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
+43
View File
@@ -129,6 +129,26 @@ try
var longChat = await bob.WaitFor(MsgType.Chat, 800);
Check(longChat == null, "Huge chat messages are not relayed");
Console.WriteLine("== Hostile clients");
alice.Send(MsgType.Chat, Wire.Build(w => w.Write("* Bob was banned by an admin")));
var fakeSystem = await bob.WaitFor(MsgType.Chat);
Check(fakeSystem != null && ReadStringAfterSender(fakeSystem) == "Bob was banned by an admin",
"Non-admins cannot send '* ' messages that look like server/admin messages");
alice.Send(MsgType.ModMessage, Wire.Build(w => { w.Write("msco.world"); w.Write(17); w.Write(new byte[17]); }));
Check(await bob.WaitFor(MsgType.ModMessage, 800) == null, "Reserved msco.* messages (time/weather) from non-admins are dropped");
alice.Send(MsgType.ModMessage, Wire.Build(w => { w.Write("somemod"); w.Write(3); w.Write(new byte[] { 1, 2, 3 }); }));
Check(await bob.WaitFor(MsgType.ModMessage, 2000) != null, "Normal mod messages are still relayed");
alice.Send(MsgType.MoneyDelta, BitConverter.GetBytes(float.NaN));
Check(await bob.WaitFor(MsgType.MoneyDelta, 800) == null, "NaN money changes are dropped (would break the shared money)");
alice.Send(MsgType.MoneyDelta, BitConverter.GetBytes(1e9f));
Check(await bob.WaitFor(MsgType.MoneyDelta, 800) == null, "Absurd money changes are dropped");
alice.Send(MsgType.MoneyDelta, BitConverter.GetBytes(25.5f));
Check(await bob.WaitFor(MsgType.MoneyDelta, 2000) != null, "Normal money changes are relayed");
alice.Send(MsgType.FsmEvent, Wire.Build(w => { w.Write("SATSUMA|x"); w.Write("Assembly"); w.Write("MSCO_INSTALL"); w.Write(""); w.Write(0f); w.Write(0f); w.Write(0f); }));
Check(await bob.WaitFor(MsgType.FsmEvent, 800) == null, "Admin part installs (MSCO_INSTALL) from non-admins are dropped");
Check(GameServer.CleanText("Eve\n[12:00] Server: you are banned\u202E", 64) == "Eve [12:00] Server: you are banned",
"Names/chat lose line breaks and invisible direction characters (no fake log lines)");
Console.WriteLine("== Bought / consumed items");
alice.Send(MsgType.ItemAppeared, Wire.Build(w => { w.Write(4242); w.Write("beer case(itemx)"); w.Write(1f); w.Write(2f); w.Write(3f); w.Write(0f); w.Write(0f); w.Write(0f); w.Write(1f); }));
Check(await bob.WaitFor(MsgType.ItemAppeared) != null, "Bought item is relayed to the other players");
@@ -419,6 +439,29 @@ async Task PanelTests()
Check(File.Exists(Path.Combine(dir, "panel_users.json")) && !File.ReadAllText(Path.Combine(dir, "panel_users.json")).Contains("supersecret1"),
"Users are stored with a password hash, not the password");
// HTTPS auf dem Spiel-Port (selbst signiertes Zertifikat)
System.Security.Cryptography.X509Certificates.X509Certificate2 seenCert = null;
var tlsHandler = new HttpClientHandler
{
CookieContainer = new System.Net.CookieContainer(), UseCookies = true,
ServerCertificateCustomValidationCallback = (_, cert, _, _) => { seenCert = cert == null ? null : new System.Security.Cryptography.X509Certificates.X509Certificate2(cert); return true; }
};
var tlsBrowser = new HttpClient(tlsHandler) { BaseAddress = new Uri($"https://127.0.0.1:{config.Port}/"), Timeout = TimeSpan.FromSeconds(10) };
var tlsPage = await tlsBrowser.GetAsync("");
Check(tlsPage.IsSuccessStatusCode && (await tlsPage.Content.ReadAsStringAsync()).Contains("MSCO Server Panel"),
"The panel also works over HTTPS on the game port");
Check(seenCert != null && host.Panel.CertificateFingerprint != null && File.Exists(Path.Combine(dir, "panel_cert.pfx"))
&& host.Panel.CertificateFingerprint.Replace(":", "") == Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(seenCert.RawData)),
"A self-signed certificate is created and its fingerprint is shown");
var tlsLogin = new HttpRequestMessage(HttpMethod.Post, "api/login")
{
Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(new { username = "owner", password = "supersecret1" }), System.Text.Encoding.UTF8, "application/json")
};
tlsLogin.Headers.Add("X-MSCO-Panel", "1");
var tlsLoginRes = await tlsBrowser.SendAsync(tlsLogin);
Check(tlsLoginRes.IsSuccessStatusCode && tlsLoginRes.Headers.TryGetValues("Set-Cookie", out var tlsCookies)
&& tlsCookies.Any(v => v.Contains("secure", StringComparison.OrdinalIgnoreCase)), "Over HTTPS the login cookie is marked Secure");
// Spiel läuft auf demselben Port weiter
var gamer = await TestClient.Join("PanelTarget", 0, null);
Check(gamer.Welcome != null, "Game clients still join on the same port");