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
+64 -5
View File
@@ -57,6 +57,8 @@ public sealed class GameServer
// ---------------------------------------------------------------- Mods (data/mods)
List<ServerMod> mods = new();
const int ModPartBytes = 60_000, MaxModMessageBytes = 64 * 1024;
/// <summary>Größte Geldänderung pro Nachricht (Einkäufe, Jobs) - mehr ist ein manipulierter Client.</summary>
const float MaxMoneyDelta = 500_000f;
/// <summary>Mods aus data/mods (neu) laden. Neue Spieler bekommen die neue Liste; Verbundene erst beim nächsten Beitritt.</summary>
public string ReloadMods()
@@ -551,11 +553,31 @@ public sealed class GameServer
{
case MsgType.ObjStates: world.ApplyObjStates(m.Payload); break;
case MsgType.ObjRelease: world.ApplyObjRelease(m.Payload); break;
case MsgType.FsmEvent: world.AddFsmEvent(m.Payload); break;
case MsgType.FsmEvent:
{
// Pfad, FSM, Event: "MSCO_INSTALL" (Teil per Admin-Menü einbauen) nur von Admins
try
{
var fr = m.Reader();
fr.ReadString(); fr.ReadString();
if (fr.ReadString() == "MSCO_INSTALL" && !c.IsAdmin) return;
}
catch { return; }
world.AddFsmEvent(m.Payload);
break;
}
case MsgType.Player:
if (DateTime.UtcNow >= c.TrackPositionFrom) world.ApplyPlayer(c.SteamId, m.Payload);
break;
case MsgType.MoneyDelta: world.ApplyMoney(m.Payload); break;
case MsgType.MoneyDelta:
{
// Ungültige Werte (NaN/unendlich) würden die gemeinsame Kasse dauerhaft zerstören - nicht annehmen
if (m.Payload.Length < 4) return;
float delta = BitConverter.ToSingle(m.Payload, 0);
if (!float.IsFinite(delta) || Math.Abs(delta) > MaxMoneyDelta) return;
world.ApplyMoney(m.Payload);
break;
}
case MsgType.DoorState: world.ApplyDoor(m.Payload); break;
case MsgType.VarSync: world.ApplyVars(m.Payload); break;
case MsgType.WorldVars: world.ApplyJobVars(m.Payload); break;
@@ -578,11 +600,28 @@ public sealed class GameServer
break;
case MsgType.ModMessage:
if (m.Payload.Length > MaxModMessageBytes) return;
// "msco.*" sind Nachrichten von MSCO selbst (z.B. msco.world = Uhrzeit/Wetter vom Admin-Menü) - nur von Admins
string modId;
try { modId = m.Reader().ReadString(); } catch { return; }
if (modId.StartsWith("msco.", StringComparison.OrdinalIgnoreCase) && !c.IsAdmin) return;
break;
case MsgType.Chat:
{
if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten
Log($"[chat] {c.Name}: {m.Reader().ReadString()}");
string text;
try { text = m.Reader().ReadString(); } catch { return; }
// "* ..." zeigt das Spiel als Server-/Admin-Meldung - nur Admins dürfen so schreiben (sonst gefälschte
// "XY was banned"-Meldungen); bei anderen wird es normaler Chat
if (text.StartsWith("* ") && !c.IsAdmin)
{
text = text.TrimStart('*', ' ');
if (text.Length == 0) return;
string clean = text;
m = new Message(m.Type, Wire.Build(w => w.Write(clean)));
}
Log($"[chat] {c.Name}: {CleanText(text, 300)}");
break;
}
case MsgType.TrafficStates:
if (c.Id != WorldAuthorityId) return; // nur einer simuliert den Verkehr
break;
@@ -607,8 +646,8 @@ public sealed class GameServer
steamId = r.ReadUInt64();
ticket = r.ReadBytes(r.ReadInt32());
}
c.Name = string.IsNullOrWhiteSpace(c.Name) ? "Player" : c.Name.Trim();
if (c.Name.Length > 24) c.Name = c.Name[..24];
c.Name = CleanText(c.Name ?? "", 24).Replace("<", "").Replace(">", "").Trim();
if (c.Name.Length == 0) c.Name = "Player";
// Spieler ohne Steam (Non-Steam-Server) werden über ihren Namen wiedererkannt (Position, Sperren)
c.SteamId = steamId != 0 ? steamId.ToString() : "name:" + c.Name.ToLowerInvariant();
@@ -1199,6 +1238,26 @@ public sealed class GameServer
}
}
/// <summary>
/// Text von einem Spieler (Name, Chat) bereinigen: keine Zeilenumbrüche/Steuerzeichen (keine gefälschten Zeilen in Log
/// und Konsole), keine unsichtbaren Richtungs-Zeichen, gekürzt.
/// </summary>
public static string CleanText(string s, int max)
{
if (string.IsNullOrEmpty(s)) return "";
var sb = new System.Text.StringBuilder(Math.Min(s.Length, max));
foreach (char ch in s)
{
if (sb.Length >= max) break;
if (char.IsControl(ch)) { sb.Append(' '); continue; }
// Richtungs-/Format-Zeichen (z.B. U+202E dreht Text um) weglassen
var cat = char.GetUnicodeCategory(ch);
if (cat == System.Globalization.UnicodeCategory.Format) continue;
sb.Append(ch);
}
return sb.ToString();
}
public static void Log(string text)
{
string time = DateTime.Now.ToString("HH:mm:ss");