From c70b284e17daa22b87b5fd4c79011b797cf1b5cf Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sat, 26 Sep 2026 23:51:18 +0200 Subject: [PATCH] Security: admin rights only for Steam-verified SteamIDs; protocol test suite (Tests/) Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 1 + GameServer.cs | 43 +++-- MSCO.Server.csproj | 8 +- README.md | 7 +- Tests/MSCO.Server.Tests.csproj | 11 ++ Tests/Program.cs | 276 +++++++++++++++++++++++++++++++++ 6 files changed, 332 insertions(+), 14 deletions(-) create mode 100644 Tests/MSCO.Server.Tests.csproj create mode 100644 Tests/Program.cs diff --git a/.dockerignore b/.dockerignore index c6be4d6..420595e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,4 @@ obj/ data/ publish/ .git/ +Tests/ diff --git a/GameServer.cs b/GameServer.cs index f8aea44..96220fd 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -27,6 +27,8 @@ public sealed class GameServer public ClientState State; public string Name = "?"; public string SteamId; + /// SteamID von Steam bestätigt (Ticket per Web API geprüft). Nur dann gibt es Admin-Rechte. + public bool SteamVerified; public bool IsAdmin; public Task Verification; } @@ -53,8 +55,12 @@ public sealed class GameServer static readonly TimeSpan ClientTimeout = TimeSpan.FromSeconds(30); bool running = true; - public GameServer(ServerConfig config, string dataDir, string configPath, string ownerName) + /// Ohne Master-Server (nur für automatische Tests): keine Heartbeats, keine Serverliste. + readonly bool offline; + + public GameServer(ServerConfig config, string dataDir, string configPath, string ownerName, bool offline = false) { + this.offline = offline; this.ownerName = ownerName; this.config = config; this.configPath = configPath; @@ -109,6 +115,9 @@ public sealed class GameServer if (!config.RequireSteam) Log("Non-Steam server: players without Steam can join (RequireSteam = false)"); else Log(steam.Enabled ? "Steam required - tickets are verified with the Steam Web API" : "Steam required (no Web API key set, tickets are not verified online)"); Log($"World: {world.Objects.Count} objects, {world.Events.Count} part/bolt actions"); + if (!steam.Enabled && config.Admins.Count > 0) + Log("WARNING: Admins are disabled - without SteamWebApiKey anyone could pretend to be an admin. " + + "Get a free key at https://steamcommunity.com/dev/apikey and put it into server.json (\"SteamWebApiKey\")."); Log("Type 'help' for commands."); nextHeartbeat = 0; @@ -126,8 +135,11 @@ public sealed class GameServer foreach (var c in clients.Values) c.Conn.Close("Server shutting down"); listener.Stop(); SaveWorld(); - master.UnregisterAsync(config.LicenseKey).Wait(TimeSpan.FromSeconds(4)); // sofort aus der Serverliste - Log("Removed from the server list."); + if (!offline) + { + master.UnregisterAsync(config.LicenseKey).Wait(TimeSpan.FromSeconds(4)); // sofort aus der Serverliste + Log("Removed from the server list."); + } Log("Server stopped."); } @@ -196,7 +208,7 @@ public sealed class GameServer else if (result.Status == 0) Log("Master server not reachable - server is not listed right now."); else ReportReachability(result); } - if (now >= nextHeartbeat && heartbeat == null) + if (!offline && now >= nextHeartbeat && heartbeat == null) { nextHeartbeat = now + HeartbeatSeconds; heartbeat = master.HeartbeatAsync(config, JoinedCount, ModVersion); @@ -362,10 +374,15 @@ public sealed class GameServer if (error != null) { Reject(c, error); return; } if (JoinedCount >= config.MaxPlayers) { Reject(c, "Server is full"); return; } - // Gleicher Steam-Account nochmal verbunden: alte Verbindung trennen + // Nur eine von Steam bestätigte SteamID gilt als Beweis. Ohne SteamWebApiKey kann jeder eine beliebige + // SteamID behaupten - damit darf er weder Admin werden noch einen verbundenen Spieler verdrängen. + c.SteamVerified = steam.Enabled && !c.SteamId.StartsWith("name:"); foreach (var other in clients.Values) - if (other != c && other.State == ClientState.Joined && other.SteamId == c.SteamId) - other.Conn.Close("Joined from another location"); + { + if (other == c || other.State != ClientState.Joined || other.SteamId != c.SteamId) continue; + if (c.SteamVerified) other.Conn.Close("Joined from another location"); // echter Account: alte Verbindung trennen + else { Reject(c, "A player with this identity is already connected"); return; } + } c.State = ClientState.Joined; var joined = clients.Values.Where(x => x.State == ClientState.Joined && !x.Conn.IsClosed).ToList(); @@ -379,7 +396,9 @@ public sealed class GameServer Broadcast(MsgType.PlayerJoined, Wire.Build(w => { w.Write(c.Id); w.Write(c.Name); }), c.Id); // Admin? Nur Spieler mit (geprüfter) SteamID können Admin sein - c.IsAdmin = !c.SteamId.StartsWith("name:") && config.Admins.Contains(c.SteamId); + c.IsAdmin = c.SteamVerified && config.Admins.Contains(c.SteamId); + if (!c.SteamVerified && config.Admins.Contains(c.SteamId)) + Log($"{c.Name} uses admin SteamID {c.SteamId}, but it cannot be verified (no SteamWebApiKey) - no admin rights"); bool isAdmin = c.IsAdmin; c.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(isAdmin))); @@ -462,7 +481,7 @@ public sealed class GameServer config.Admins.Remove(target); if (makeAdmin) config.Admins.Add(target); config.Save(configPath); - foreach (var online in clients.Values.Where(x => x.SteamId == target)) + foreach (var online in clients.Values.Where(x => x.SteamId == target && x.SteamVerified)) { online.IsAdmin = makeAdmin; online.Conn.Send(MsgType.AdminStatus, Wire.Build(w => w.Write(makeAdmin))); @@ -475,9 +494,11 @@ public sealed class GameServer Log("Unknown player id (see 'players') - or use a SteamID64"); break; } - if (who.SteamId.StartsWith("name:")) + if (!who.SteamVerified) { - Log("Only players with Steam can be admins (their SteamID is verified)"); + Log(steam.Enabled + ? "Only players with Steam can be admins" + : "Admins need a verified SteamID - set SteamWebApiKey in server.json (https://steamcommunity.com/dev/apikey)"); break; } config.Admins.Remove(who.SteamId); diff --git a/MSCO.Server.csproj b/MSCO.Server.csproj index 3e586e3..8425e52 100644 --- a/MSCO.Server.csproj +++ b/MSCO.Server.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,4 +15,10 @@ logo.ico + + + + + + diff --git a/README.md b/README.md index ea7a40d..4e507c4 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,9 @@ und neu starten. | `RequireSteam` | `true` = nur Spieler mit Steam; `false` = Non-Steam-Server (Spieler werden über den Namen erkannt) | | `PublicAddress` | öffentliche IP/Domain für die Liste (nur hinter NAT/Proxy nötig) | | `AutosaveSeconds` | wie oft der Weltzustand gespeichert wird | -| `SteamWebApiKey` | optional: Steam-Anmeldetickets der Spieler bei Steam prüfen | +| `SteamWebApiKey` | Steam-Anmeldetickets der Spieler bei Steam prüfen – **nötig für Admins** (kostenlos: https://steamcommunity.com/dev/apikey) | | `BannedSteamIds` | gesperrte Spieler | -| `Admins` | SteamID64s, die im Spiel mit `/admin` kicken/bannen dürfen | +| `Admins` | SteamID64s, die im Spiel mit `/admin` kicken/bannen dürfen (nur mit `SteamWebApiKey`, siehe Sicherheit) | Fehlende Einstellungen ergänzt der Server beim Start automatisch. Umgebungsvariablen (`MSCO_LICENSE_KEY`, `MSCO_NAME`, `MSCO_PORT`, `MSCO_MAX_PLAYERS`, `MSCO_PUBLIC`, `MSCO_REQUIRE_STEAM`, `MSCO_PUBLIC_ADDRESS`, @@ -187,6 +187,9 @@ einen aktuellen Spielstand nach `data/save/` legen und `msco reset-world` ausfü - `data/` enthält Lizenzschlüssel und API-Keys – nicht veröffentlichen (steht in `.gitignore`). - Der Container läuft nicht als root. Spielerdaten (Nachrichten, Objektzustände) werden in der Größe begrenzt, unbekannte oder zu große Nachrichten verworfen. +- **Admins nur mit `SteamWebApiKey`:** Ohne Key prüft der Server die Steam-Tickets nicht – jeder könnte dann eine + fremde SteamID (z.B. die eines Admins) angeben. Deshalb gibt es ohne Key keine Admin-Rechte (das Log warnt beim + Start), und eine unbestätigte SteamID kann keinen verbundenen Spieler verdrängen. - Gebannte Spieler werden über ihre (bei Steam geprüfte) SteamID erkannt. Auf Non-Steam-Servern gibt es keine verlässliche Identität – dort sind Bans nur über den Namen möglich. diff --git a/Tests/MSCO.Server.Tests.csproj b/Tests/MSCO.Server.Tests.csproj new file mode 100644 index 0000000..56f6c1a --- /dev/null +++ b/Tests/MSCO.Server.Tests.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + disable + enable + + + + + diff --git a/Tests/Program.cs b/Tests/Program.cs new file mode 100644 index 0000000..3fa71fd --- /dev/null +++ b/Tests/Program.cs @@ -0,0 +1,276 @@ +using System.Net.Sockets; +using MSCO.Server; + +// Protokoll-Tests gegen den echten Server-Code: startet einen Server offline (ohne Master, ohne Lizenz) auf +// Port 17777 und spielt mehrere Clients durch - Beitreten, Ping, Weltzustand, Chat, Admin-Rechte, kaputte Daten. +// Ausführen im Server-Ordner: dotnet run --project Tests +const int Port = 17777; +const string AdminId = "76561198910227878"; +string dir = Path.Combine(Path.GetTempPath(), "msco-servertest-" + Guid.NewGuid().ToString("N")[..8]); +Directory.CreateDirectory(Path.Combine(dir, "save")); +// Platzhalter-Spielstand (der Inhalt ist für die Protokoll-Tests egal) +foreach (var name in new[] { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" }) + File.WriteAllText(Path.Combine(dir, "save", name), "test " + name); +string configPath = Path.Combine(dir, "server.json"); +var config = new ServerConfig { LicenseKey = "test", Name = "Test", Port = Port, MaxPlayers = 4, RequireSteam = false, Public = false, Admins = new() { AdminId } }; +config.Save(configPath); + +var server = new GameServer(config, dir, configPath, "Tester", offline: true); +if (!server.LoadSave()) { Console.WriteLine("FAIL: LoadSave"); return 1; } +var serverThread = new Thread(server.Run) { IsBackground = true }; +serverThread.Start(); +Thread.Sleep(500); + +int failed = 0, passed = 0; +void Check(bool ok, string what) { if (ok) passed++; else failed++; Console.WriteLine((ok ? " ok " : " FAIL ") + what); } + +try +{ + Console.WriteLine("== Join"); + var alice = await TestClient.Join("Alice", 0, null); + Check(alice.Welcome != null, "Alice gets Welcome"); + Check(alice.Id > 0, "Alice has id " + alice.Id); + Check(alice.AdminStatus == false, "Alice is not admin"); + Check(alice.SaveFiles == 5, "Alice received save files (" + alice.SaveFiles + ")"); + Check(alice.GotStartGame, "Alice got StartGame"); + + Console.WriteLine("== Ping"); + alice.Send(MsgType.Ping, BitConverter.GetBytes(12.5f)); + var pong = await alice.WaitFor(MsgType.Pong); + Check(pong != null && BitConverter.ToSingle(pong, 0) == 12.5f, "Ping is echoed as Pong with the same timestamp"); + + Console.WriteLine("== Ready / snapshot"); + alice.Send(MsgType.Ready, Array.Empty()); + var snap = await alice.WaitFor(MsgType.WorldSnapshot); + Check(snap != null, "WorldSnapshot received"); + var s0 = Snapshot.Parse(snap); + Check(s0.Ok, "Snapshot parses completely (" + s0.Info + ")"); + + Console.WriteLine("== Second player, chat"); + var bob = await TestClient.Join("Bob", 0, null); + Check(bob.Welcome != null, "Bob joins"); + var joined = await alice.WaitFor(MsgType.PlayerJoined); + Check(joined != null, "Alice is told that Bob joined"); + alice.Send(MsgType.Chat, Wire.Build(w => w.Write("hello bob"))); + var chat = await bob.WaitFor(MsgType.Chat); + Check(chat != null && new BinaryReader(new MemoryStream(chat)).ReadInt32() == alice.Id, "Bob receives Alice's chat with her id"); + Check(chat != null && ReadStringAfterSender(chat) == "hello bob", "Chat text arrives unchanged"); + + Console.WriteLine("== Object states (valid / invalid)"); + alice.Send(MsgType.ObjStates, ObjStates((111, 10, 1, 20, 0, 0, 0, 1), (222, float.NaN, 0, 0, 0, 0, 0, 1), (333, 0, -5000, 0, 0, 0, 0, 1), (444, 1, 1, 1, 0, 0, 0, 0))); + var relay = await bob.WaitFor(MsgType.ObjStates); + Check(relay != null, "Object states are relayed to Bob"); + alice.Send(MsgType.VarSync, Wire.Build(w => { w.Write(2); w.Write(42); w.Write((byte)1); w.Write(3.5f); w.Write(43); w.Write((byte)3); w.Write(true); })); + await bob.WaitFor(MsgType.VarSync); + var carol = await TestClient.Join("Carol", 0, null); + carol.Send(MsgType.Ready, Array.Empty()); + var snap2 = Snapshot.Parse(await carol.WaitFor(MsgType.WorldSnapshot)); + Check(snap2.ObjectIds.Contains(111), "Valid object pose is stored"); + Check(!snap2.ObjectIds.Contains(222), "NaN pose is rejected"); + Check(!snap2.ObjectIds.Contains(333), "Pose far below the map is rejected"); + Check(!snap2.ObjectIds.Contains(444), "Invalid quaternion is rejected"); + Check(snap2.Vars == 2, "Vehicle values are stored and sent to new players (" + snap2.Vars + ")"); + + Console.WriteLine("== Server full"); + var eve = await TestClient.Join("Eve", 0, null); + var dave = await TestClient.Join("Dave", 0, null, expectReject: true); + Check(dave.RejectReason != null && dave.RejectReason.Contains("full"), "Player over the limit is rejected: " + dave.RejectReason); + eve.Close(); + + Console.WriteLine("== Admin rights"); + alice.Send(MsgType.AdminListRequest, Array.Empty()); + var notAdmin = await alice.WaitFor(MsgType.AdminResult); + Check(notAdmin != null && ReadString(notAdmin).Contains("not an admin"), "Non-admin cannot open the admin list"); + alice.Send(MsgType.AdminAction, Wire.Build(w => { w.Write((byte)1); w.Write(bob.Id); w.Write("x"); })); + var kickTry = await alice.WaitFor(MsgType.AdminResult); + Check(kickTry != null && ReadString(kickTry).Contains("not an admin"), "Non-admin cannot kick"); + carol.Close(); + await Task.Delay(300); + + // Jemand gibt sich mit der SteamID des Admins aus (ohne Steam Web API Key wird das Ticket nicht geprüft) + var faker = await TestClient.Join("Faker", ulong.Parse(AdminId), new byte[] { 1, 2, 3, 4 }); + Check(faker.Welcome != null, "Player with a claimed SteamID joins (tickets not verified without key)"); + Check(faker.AdminStatus == false, "Claimed (unverified) SteamID must NOT get admin rights"); + var faker2 = await TestClient.Join("Faker2", ulong.Parse(AdminId), new byte[] { 9 }, expectReject: true); + Check(faker2.RejectReason != null && faker2.RejectReason.Contains("already connected"), + "Second player with the same unverified SteamID cannot push out the first: " + faker2.RejectReason); + Check(!faker.IsClosedByServer(), "The first player stays connected"); + faker.Close(); + await Task.Delay(300); + + Console.WriteLine("== Robustness"); + var evil = await TestClient.Join("Evil", 0, null); + evil.SendRaw(BitConverter.GetBytes(5 * 1024 * 1024)); // Paket, das 5 MB groß sein will + await Task.Delay(500); + Check(evil.IsClosedByServer(), "Oversized packet closes only that connection"); + var bob2 = await TestClient.Join("Bob2", 0, null); + Check(bob2.Welcome != null, "Server still accepts players afterwards"); + bob2.Send(MsgType.VarSync, new byte[] { 1, 2 }); // kaputte Nachricht + await Task.Delay(500); + Check(bob2.IsClosedByServer(), "Malformed message closes only that connection"); + alice.Send(MsgType.Ping, BitConverter.GetBytes(1f)); + Check(await alice.WaitFor(MsgType.Pong) != null, "Other players are not affected"); + + Console.WriteLine("== Chat limits"); + alice.Send(MsgType.Chat, Wire.Build(w => w.Write(new string('x', 5000)))); + var longChat = await bob.WaitFor(MsgType.Chat, 800); + Check(longChat == null, "Huge chat messages are not relayed"); +} +catch (Exception e) +{ + failed++; + Console.WriteLine(" FAIL exception: " + e); +} + +Console.WriteLine($"\n{passed} passed, {failed} failed"); +server.EnqueueCommand("stop"); +Thread.Sleep(300); +try { Directory.Delete(dir, true); } catch { } +return failed == 0 ? 0 : 1; + +static string ReadString(byte[] p) => new BinaryReader(new MemoryStream(p)).ReadString(); +static string ReadStringAfterSender(byte[] p) { var r = new BinaryReader(new MemoryStream(p)); r.ReadInt32(); return r.ReadString(); } + +static byte[] ObjStates(params (int id, float x, float y, float z, float qx, float qy, float qz, float qw)[] items) => Wire.Build(w => +{ + w.Write(items.Length); + foreach (var i in items) + { + w.Write(i.id); w.Write(i.x); w.Write(i.y); w.Write(i.z); + w.Write(i.qx); w.Write(i.qy); w.Write(i.qz); w.Write(i.qw); + w.Write(0f); w.Write(0f); w.Write(0f); + } +}); + +sealed class Snapshot +{ + public bool Ok; + public string Info; + public HashSet ObjectIds = new(); + public int Vars = -1; + + public static Snapshot Parse(byte[] data) + { + var s = new Snapshot(); + if (data == null) { s.Info = "no data"; return s; } + try + { + var r = new BinaryReader(new MemoryStream(data)); + if (r.ReadBoolean()) { r.ReadSingle(); r.ReadSingle(); r.ReadSingle(); } + int objects = r.ReadInt32(); + for (int i = 0; i < objects; i++) { s.ObjectIds.Add(r.ReadInt32()); for (int k = 0; k < 7; k++) r.ReadSingle(); } + int events = r.ReadInt32(); + for (int i = 0; i < events; i++) r.ReadBytes(r.ReadInt32()); + float money = r.ReadSingle(); + int doors = r.ReadInt32(); + for (int i = 0; i < doors; i++) { r.ReadString(); r.ReadString(); } + s.Vars = r.ReadInt32(); + for (int i = 0; i < s.Vars; i++) { r.ReadInt32(); byte t = r.ReadByte(); r.ReadBytes(t == 3 ? 1 : 4); } + s.Ok = r.BaseStream.Position == r.BaseStream.Length; + s.Info = $"{objects} objects, {events} events, money {money}, {doors} doors, {s.Vars} vars"; + } + catch (Exception e) { s.Info = "parse error " + e.Message; } + return s; + } +} + +sealed class TestClient +{ + const int Port = 17777; + TcpClient tcp; + NetworkStream stream; + readonly List<(MsgType type, byte[] payload)> inbox = new(); + readonly object gate = new(); + bool closed; + + public byte[] Welcome; + public int Id; + public bool? AdminStatus; + public int SaveFiles; + public bool GotStartGame; + public string RejectReason; + + public static async Task Join(string name, ulong steamId, byte[] ticket, bool expectReject = false) + { + var c = new TestClient { tcp = new TcpClient() }; + await c.tcp.ConnectAsync("127.0.0.1", Port); + c.stream = c.tcp.GetStream(); + _ = Task.Run(c.ReadLoop); + c.Send(MsgType.Hello, Wire.Build(w => + { + w.Write("2.6"); + w.Write(name); + w.Write(steamId); + w.Write(ticket?.Length ?? 0); + if (ticket != null) w.Write(ticket); + })); + if (expectReject) + { + var rej = await c.WaitFor(MsgType.Reject); + if (rej != null) c.RejectReason = new BinaryReader(new MemoryStream(rej)).ReadString(); + return c; + } + c.Welcome = await c.WaitFor(MsgType.Welcome); + if (c.Welcome != null) c.Id = BitConverter.ToInt32(c.Welcome, 0); + var admin = await c.WaitFor(MsgType.AdminStatus); + if (admin != null) c.AdminStatus = admin[0] != 0; + await c.WaitFor(MsgType.SaveBegin); + while (await c.WaitFor(MsgType.SaveFile, 1500) != null) c.SaveFiles++; + c.GotStartGame = await c.WaitFor(MsgType.StartGame, 1500) != null || c.SaveFiles > 0; + return c; + } + + async Task ReadLoop() + { + var header = new byte[4]; + try + { + while (true) + { + await ReadExact(header, 4); + int len = BitConverter.ToInt32(header, 0); + var body = new byte[len]; + await ReadExact(body, len); + lock (gate) inbox.Add(((MsgType)body[0], body[1..])); + } + } + catch { closed = true; } + } + + async Task ReadExact(byte[] buf, int count) + { + int off = 0; + while (off < count) + { + int n = await stream.ReadAsync(buf.AsMemory(off, count - off)); + if (n <= 0) throw new IOException("closed"); + off += n; + } + } + + public void Send(MsgType type, byte[] payload) + { + byte[] frame = Wire.Frame(type, payload); + try { stream.Write(frame, 0, frame.Length); } catch { closed = true; } + } + + public void SendRaw(byte[] data) { try { stream.Write(data, 0, data.Length); stream.Write(new byte[16], 0, 16); } catch { closed = true; } } + + public async Task WaitFor(MsgType type, int timeoutMs = 3000) + { + var end = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < end) + { + lock (gate) + { + int i = inbox.FindIndex(m => m.type == type); + if (i >= 0) { var p = inbox[i].payload; inbox.RemoveAt(i); return p; } + } + await Task.Delay(20); + } + return null; + } + + public bool IsClosedByServer() => closed; + public void Close() { try { tcp.Close(); } catch { } } +}