From 05721ccddaff4ef5dcd165b05e5a77d593f79ef3 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Sat, 10 Oct 2026 11:18:42 -0400 Subject: [PATCH 1/3] Scripting: cover client-reported Epic ID whitelists --- .../src/networking/rpc/client_identity.h | 4 +-- code/tests/modules/connection_admission_ut.h | 6 +++-- code/tests/modules/connection_gate_ut.h | 27 +++++++++++++++++++ docs/connection_admission.md | 17 ++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/code/framework/src/networking/rpc/client_identity.h b/code/framework/src/networking/rpc/client_identity.h index 72eeb2f63..e134d70e1 100644 --- a/code/framework/src/networking/rpc/client_identity.h +++ b/code/framework/src/networking/rpc/client_identity.h @@ -34,8 +34,8 @@ namespace Framework::Networking::RPC { std::string steamId; std::string discordId; std::string hardwareId; - // Authenticated Epic account id; empty when the game was not launched through Epic. Ordered - // to match Decode(), which is strict: a payload missing any field is refused outright. + // Client-reported Epic account id, not authenticated by the server; empty when absent. + // Ordered to match Decode(), which refuses a payload missing any field. std::string epicId; // Opaque string the client was launched with (a launcher-issued join ticket, typically). diff --git a/code/tests/modules/connection_admission_ut.h b/code/tests/modules/connection_admission_ut.h index d3a027184..0fb8459ca 100644 --- a/code/tests/modules/connection_admission_ut.h +++ b/code/tests/modules/connection_admission_ut.h @@ -98,10 +98,11 @@ class ConnectionAdmissionRig { return static_cast(client->Connect("127.0.0.1", port, "", sessionPayload)); } - static std::string IdentityPayload(const char *name, const char *ticket) { + static std::string IdentityPayload(const char *name, const char *ticket, const char *epicId = "") { Framework::Networking::RPC::ClientIdentity identity; identity.name = name; identity.ticket = ticket; + identity.epicId = epicId; return identity.Encode(); } @@ -138,12 +139,13 @@ MODULE(connection_admission, { IT("hands the server the identity and ticket the client connected with, before any connection", { Rig rig; EQUALS(rig.StartServer(true), true); - EQUALS(rig.Connect(Rig::IdentityPayload("Jan", "tkt-abc+123")), true); + EQUALS(rig.Connect(Rig::IdentityPayload("Jan", "tkt-abc+123", "0123456789abcdef0123456789abcdef")), true); EQUALS(rig.PumpUntil([&] { return !rig.seen.requests.empty(); }), true); EQUALS(rig.seen.requests[0].has_value(), true); STREQUALS(rig.seen.requests[0]->name.c_str(), "Jan"); STREQUALS(rig.seen.requests[0]->ticket.c_str(), "tkt-abc+123"); + STREQUALS(rig.seen.requests[0]->epicId.c_str(), "0123456789abcdef0123456789abcdef"); // Waiting: nobody is connected on either side, and the player count says so. rig.PumpFor(300); diff --git a/code/tests/modules/connection_gate_ut.h b/code/tests/modules/connection_gate_ut.h index d3a60aab6..2ca971cf2 100644 --- a/code/tests/modules/connection_gate_ut.h +++ b/code/tests/modules/connection_gate_ut.h @@ -64,6 +64,12 @@ class ConnectionGateTestRig { globalThis.__probe = connection; globalThis.__pendingDuring = connection.isPending() ? 1 : 0; return; + case "epic-whitelist": + globalThis.__epicId = connection.epicId; + if (connection.epicId !== "0123456789abcdef0123456789abcdef") { + connection.reject("Your Epic account is not on the whitelist."); + } + return; } }); )"; @@ -186,6 +192,27 @@ MODULE(connection_gate, { STREQUALS(Rig::EvalString(module.GetEngine(), "globalThis.__seen").c_str(), "admit|one-time-ticket|10.0.0.2|76561198000000000"); }); + IT("admits an Epic whitelist entry using the client-reported ID without a ticket", { + auto identity = Rig::Identity("epic-whitelist"); + identity.steamId.clear(); + identity.ticket.clear(); + identity.epicId = "0123456789abcdef0123456789abcdef"; + EQUALS(gate.Begin(Rig::Guid(12), identity, "10.0.0.12"), true); + const auto decisions = Rig::Settle(module, gate); + EQUALS(decisions.size(), 1u); + EQUALS(decisions[0].admitted, true); + STREQUALS(Rig::EvalString(module.GetEngine(), "globalThis.__epicId").c_str(), identity.epicId.c_str()); + }); + + IT("lets an Epic whitelist refuse a connection without an Epic ID", { + EQUALS(gate.Begin(Rig::Guid(13), Rig::Identity("epic-whitelist"), "10.0.0.13"), true); + const auto decisions = Rig::Settle(module, gate); + EQUALS(decisions.size(), 1u); + EQUALS(decisions[0].admitted, false); + STREQUALS(decisions[0].reason.c_str(), "Your Epic account is not on the whitelist."); + STREQUALS(Rig::EvalString(module.GetEngine(), "globalThis.__epicId").c_str(), ""); + }); + IT("refuses with the script's reason", { EQUALS(gate.Begin(Rig::Guid(3), Rig::Identity("refuse"), "10.0.0.3"), true); const auto decisions = Rig::Settle(module, gate); diff --git a/docs/connection_admission.md b/docs/connection_admission.md index 0f8910ea9..0be13d351 100644 --- a/docs/connection_admission.md +++ b/docs/connection_admission.md @@ -105,6 +105,7 @@ waits up to two minutes for an answer; it is always the server that decides. |---|---| | `nickname` | the name the player asked to join under | | `steamId`, `discordId`, `hardwareId` | as reported by the client; empty when absent | +| `epicId` | Epic account ID reported by the client; empty when absent | | `ticket` | the `ticket` of the client's launch link, untouched | | `ip` | remote address, no port | | `reject(reason?)` | refuse; reason up to 512 bytes | @@ -116,6 +117,22 @@ itself; `steamId` is read from an environment variable the launcher sets and is not checked against Steam. A whitelist that must hold against a modified client should check the `ticket` with whatever issued it. +The Epic launcher sets `MafiaHubEpicId` from its signed-in account, and the client +includes that ID in the existing connection handshake. The server applies a hex +format check and exposes it as `PendingConnection.epicId` before admission and +`Player.epicId` after joining. It is an Epic account ID, not an EOS Product User +ID. No Epic proof or server-side account verification is performed. + +A whitelist using the client-reported Epic ID can check it directly: + +```js +Events.on("playerConnecting", async (connection) => { + if (!connection.epicId || !await whitelist.containsEpicAccount(connection.epicId)) { + connection.reject("Your Epic account is not on the whitelist."); + } +}); +``` + --- ## 5. Passing a ticket From d5a84995c85124fe0b8dd1de140001ea7b2eb0b7 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Mon, 12 Oct 2026 12:27:04 -0400 Subject: [PATCH 2/3] Scripting: clarify Epic ID filtering and rejection coverage --- code/tests/modules/connection_gate_ut.h | 13 +++++++++++++ docs/connection_admission.md | 7 ++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/code/tests/modules/connection_gate_ut.h b/code/tests/modules/connection_gate_ut.h index 2ca971cf2..736e961f1 100644 --- a/code/tests/modules/connection_gate_ut.h +++ b/code/tests/modules/connection_gate_ut.h @@ -213,6 +213,19 @@ MODULE(connection_gate, { STREQUALS(Rig::EvalString(module.GetEngine(), "globalThis.__epicId").c_str(), ""); }); + IT("lets an Epic whitelist refuse a nonempty ID that is not listed", { + auto identity = Rig::Identity("epic-whitelist"); + identity.steamId.clear(); + identity.ticket.clear(); + identity.epicId = "fedcba9876543210fedcba9876543210"; + EQUALS(gate.Begin(Rig::Guid(14), identity, "10.0.0.14"), true); + const auto decisions = Rig::Settle(module, gate); + EQUALS(decisions.size(), 1u); + EQUALS(decisions[0].admitted, false); + STREQUALS(decisions[0].reason.c_str(), "Your Epic account is not on the whitelist."); + STREQUALS(Rig::EvalString(module.GetEngine(), "globalThis.__epicId").c_str(), identity.epicId.c_str()); + }); + IT("refuses with the script's reason", { EQUALS(gate.Begin(Rig::Guid(3), Rig::Identity("refuse"), "10.0.0.3"), true); const auto decisions = Rig::Settle(module, gate); diff --git a/docs/connection_admission.md b/docs/connection_admission.md index 0be13d351..f320233af 100644 --- a/docs/connection_admission.md +++ b/docs/connection_admission.md @@ -123,7 +123,12 @@ format check and exposes it as `PendingConnection.epicId` before admission and `Player.epicId` after joining. It is an Epic account ID, not an EOS Product User ID. No Epic proof or server-side account verification is performed. -A whitelist using the client-reported Epic ID can check it directly: +For temporary, non-security filtering only, a handler can compare the +client-reported Epic ID with a list. This does not authenticate the player: a +modified client can submit a listed ID. For an authorization whitelist, validate +`connection.ticket` with its issuer before admitting the player. + +The example below performs only the client-reported ID filter: ```js Events.on("playerConnecting", async (connection) => { From 2c811b003a4a2485e9e2f5018f15b9125f03ea9e Mon Sep 17 00:00:00 2001 From: KHeartz Date: Mon, 12 Oct 2026 12:45:52 -0400 Subject: [PATCH 3/3] Scripting: test Epic ID access after player admission --- code/tests/modules/connection_gate_ut.h | 66 ++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/code/tests/modules/connection_gate_ut.h b/code/tests/modules/connection_gate_ut.h index 736e961f1..097e1b93d 100644 --- a/code/tests/modules/connection_gate_ut.h +++ b/code/tests/modules/connection_gate_ut.h @@ -8,9 +8,14 @@ #pragma once +#include "connection_admission_ut.h" + #include "core_modules.h" #include "integrations/server/connection_gate.h" #include "integrations/server/scripting/module.h" +#include "networking/replication/network_entity.h" +#include "networking/replication/replication_manager.h" +#include "scripting/builtins/player.h" #include "scripting/node_engine.h" #include "scripting/resource/resource_manager.h" @@ -26,10 +31,32 @@ // The admission gate against a real Node engine and a real resource: what a playerConnecting // handler does -- return, reject, throw, await, hang -- and what the gate decides for it. The -// network side is left out (no NetworkServer, so update() sends nothing); the decisions are what -// the instance acts on, and those are what is asserted. +// decisions are what the instance acts on. A loopback admission also checks the joined player's +// script-visible identity. class ConnectionGateTestRig { public: + // Register an admitted peer's avatar for the same owner-guid lookup used by server scripts. + struct JoinedPlayer { + Framework::Networking::NetworkServer &server; + Framework::Networking::Replication::NetworkEntity avatar; + + JoinedPlayer(Framework::Networking::NetworkServer &peer, MafiaNet::RakNetGUID guid): server(peer) { + avatar.SetNetworkID(1); + avatar.ownerGUID = MafiaNet::ToPeerGuid(guid); + server.GetReplicationManager()->Reference(&avatar); + server.GetReplicationManager()->SetViewer(avatar.ownerGUID, &avatar); + Framework::CoreModules::SetNetworkPeer(&server); + Framework::CoreModules::SetReplication(server.GetReplicationManager()); + } + + ~JoinedPlayer() { + Framework::CoreModules::SetReplication(nullptr); + Framework::CoreModules::SetNetworkPeer(nullptr); + server.GetReplicationManager()->ClearViewer(avatar.ownerGUID); + server.GetReplicationManager()->Dereference(&avatar); + } + }; + static std::string ResourcesPath() { #ifdef _WIN32 const char *temp = std::getenv("TEMP"); @@ -204,6 +231,41 @@ MODULE(connection_gate, { STREQUALS(Rig::EvalString(module.GetEngine(), "globalThis.__epicId").c_str(), identity.epicId.c_str()); }); + IT("exposes the admitted peer's Epic ID through script-visible Player.epicId after joining", { + ConnectionAdmissionRig network; + EQUALS(network.StartServer(true), true); + const char *epicId = "0123456789abcdef0123456789abcdef"; + EQUALS(network.Connect(ConnectionAdmissionRig::IdentityPayload("epic-whitelist", "", epicId)), true); + EQUALS(network.PumpUntil([&] { return !network.seen.requests.empty(); }), true); + EQUALS(network.seen.requests[0].has_value(), true); + + const auto guid = network.seen.requestGuids[0]; + const auto &identity = *network.seen.requests[0]; + // The instance retains the received identity before running playerConnecting. + network.server->SetPeerIdentity(guid, identity); + EQUALS(gate.Begin(guid, identity, network.server->GetAddress(guid)), true); + const auto decisions = Rig::Settle(module, gate); + EQUALS(decisions.size(), 1u); + EQUALS(decisions[0].guid.g, guid.g); + EQUALS(decisions[0].admitted, true); + network.server->AcceptSession(decisions[0].guid); + EQUALS(network.PumpUntil([&] { return network.seen.clientConnected && network.server->GetPlayerCount() == 1u; }), true); + EQUALS(network.seen.clientDisconnected, false); + + Rig::JoinedPlayer player(*network.server, guid); + auto *engine = module.GetEngine(); + v8::Isolate *isolate = engine->GetResourceRuntime("gate")->GetIsolate(); + { + v8::Locker locker(isolate); + v8::Isolate::Scope isolateScope(isolate); + v8::HandleScope handleScope(isolate); + v8::Local context = engine->GetContext(); + v8::Context::Scope contextScope(context); + Framework::Scripting::Builtins::Player::Register(isolate, context->Global()); + } + STREQUALS(Rig::EvalString(engine, "new Player(1).epicId").c_str(), epicId); + }); + IT("lets an Epic whitelist refuse a connection without an Epic ID", { EQUALS(gate.Begin(Rig::Guid(13), Rig::Identity("epic-whitelist"), "10.0.0.13"), true); const auto decisions = Rig::Settle(module, gate);