From 8e7ac3c8720091ec3a703e8c2bfe472febf6d429 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:07:47 +0200 Subject: [PATCH] Pick a long bracket the json does not close in rcon commands (#506) exp_reports, exp_roles and exp_groups wrapped the json they send to Lua in a fixed [=[ ]=] long bracket. A report reason, role name or group name containing ]=] closed it early and the rest ran as Lua through /sc. Any player can make a report, so any player could run code on the server. Use the next level the json does not contain, like the other plugins do. Co-authored-by: Claude Opus 5.5 --- exp_groups/instance.ts | 8 +++++++- exp_reports/instance.ts | 8 +++++++- exp_reports/test/instance.test.js | 19 ++++++++++++++++--- exp_roles/instance.ts | 8 +++++++- exp_roles/test/instance.test.js | 17 ++++++++++++++--- 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/exp_groups/instance.ts b/exp_groups/instance.ts index afc8e591..76aa3a83 100644 --- a/exp_groups/instance.ts +++ b/exp_groups/instance.ts @@ -29,7 +29,13 @@ export default async function (context: InstancePluginContext) { const onlinePlayers = new Set(); async function luaSend(receiver: string, json: any) { - await instance.sendRcon(`/sc exp_groups.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true) + const text = JSON.stringify(json); + // Group names can contain anything, so pick a long bracket level the json does not close + let level = "="; + while (text.includes(`]${level}]`)) { + level += "="; + } + await instance.sendRcon(`/sc exp_groups.${receiver}(helpers.json_to_table[${level}[${text}]${level}])`, true) } async function updatePlayerAssignmentSubscription(playerName: string, action: "subscribe" | "unsubscribe") { diff --git a/exp_reports/instance.ts b/exp_reports/instance.ts index 1ca29d95..ab41fd43 100644 --- a/exp_reports/instance.ts +++ b/exp_reports/instance.ts @@ -36,8 +36,14 @@ export default async function (context: InstancePluginContext) { if (instance.status !== "running") { return; } + const text = JSON.stringify(json); + // Reasons can contain anything, so pick a long bracket level the json does not close + let level = "="; + while (text.includes(`]${level}]`)) { + level += "="; + } await instance.sendRcon( - `/sc exp_reports.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true + `/sc exp_reports.${receiver}(helpers.json_to_table[${level}[${text}]${level}])`, true ); } diff --git a/exp_reports/test/instance.test.js b/exp_reports/test/instance.test.js index 9353f34d..7e748d82 100644 --- a/exp_reports/test/instance.test.js +++ b/exp_reports/test/instance.test.js @@ -74,10 +74,13 @@ async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2, }; } -/** The lua receiver and payload of a recorded rcon command. */ +/** The lua receiver and payload of a recorded rcon command, throws if the json closes its long bracket. */ function decodeRcon(command) { - const match = command.match(/^\/sc exp_reports\.(\w+)\(helpers\.json_to_table\[=\[(.*)\]=\]\)$/s); - return { receiver: match[1], payload: JSON.parse(match[2]) }; + const match = command.match(/^\/sc exp_reports\.(\w+)\(helpers\.json_to_table\[(=+)\[(.*)\]\2\]\)$/s); + if (match[3].includes(`]${match[2]}]`)) { + throw new Error(`json closes the long bracket: ${command}`); + } + return { receiver: match[1], payload: JSON.parse(match[3]) }; } t.test("instance plugin", t2 => { @@ -106,6 +109,16 @@ t.test("instance plugin", t2 => { t3.strictSame(payload, { caller: "dave", message: "dave has already reported bob" }); }); + t2.test("text that closes a long bracket stays inside the json", async t3 => { + const { state, createIPC } = await startPlugin(t3); + const message = "]=]) game.print(1) --[==[ ]==] ]===]"; + state.failNext = new lib.RequestError(message); + await createIPC({ player_name: "bob", by_player_name: "dave", reason: message }); + + const { payload } = decodeRcon(state.rcons[0]); + t3.strictSame(payload.message, message); + }); + t2.test("exp_reports:list lists the reports for the caller", async t3 => { const { state, listIPC } = await startPlugin(t3); await listIPC({ caller: "admin", player_name: "bob" }); diff --git a/exp_roles/instance.ts b/exp_roles/instance.ts index a855d83e..4aec29dc 100644 --- a/exp_roles/instance.ts +++ b/exp_roles/instance.ts @@ -14,8 +14,14 @@ export default async function (context: InstancePluginContext) { const syncMode = () => instance.config.get("exp_roles.sync_mode"); async function luaSend(receiver: string, json: any) { + const text = JSON.stringify(json); + // Role names and tags can contain anything, so pick a long bracket level the json does not close + let level = "="; + while (text.includes(`]${level}]`)) { + level += "="; + } await instance.sendRcon( - `/sc exp_roles.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true + `/sc exp_roles.${receiver}(helpers.json_to_table[${level}[${text}]${level}])`, true ); } diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js index 0876474a..6ed398b7 100644 --- a/exp_roles/test/instance.test.js +++ b/exp_roles/test/instance.test.js @@ -83,10 +83,13 @@ async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles }; } -/** The lua receiver and payload of a recorded rcon command. */ +/** The lua receiver and payload of a recorded rcon command, throws if the json closes its long bracket. */ function decodeRcon(command) { - const match = command.match(/^\/sc exp_roles\.(\w+)\(helpers\.json_to_table\[=\[(.*)\]=\]\)$/s); - return { receiver: match[1], payload: JSON.parse(match[2]) }; + const match = command.match(/^\/sc exp_roles\.(\w+)\(helpers\.json_to_table\[(=+)\[(.*)\]\2\]\)$/s); + if (match[3].includes(`]${match[2]}]`)) { + throw new Error(`json closes the long bracket: ${command}`); + } + return { receiver: match[1], payload: JSON.parse(match[3]) }; } t.test("entrypoint", async t2 => { @@ -161,6 +164,14 @@ t.test("instance plugin", t2 => { t3.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded"); }); + t2.test("role names that close a long bracket stay inside the json", async t3 => { + const { state, roleUpdated } = await startPlugin(t3); + const records = sampleRoles(); + records[0].name = "]=]) game.print(1) --[==[ ]==]"; + await roleUpdated(new messages.RoleUpdatedEvent(records)); + t3.strictSame(decodeRcon(state.rcons[0]).payload[0].name, records[0].name); + }); + t2.test("role and assignment updates are gated while disabled", async t3 => { const { state, roleUpdated, assignmentUpdated } = await startPlugin(t3, { syncMode: "disabled" }); await roleUpdated(new messages.RoleUpdatedEvent(sampleRoles()));