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 <noreply@anthropic.com>
This commit is contained in:
BastiaanandClaude Opus 5.5 authored and GitHub committed 2026-10-15 17:07:47 +01:00
1 parent 55bdf72fff
commit 8e7ac3c872
5 files changed
+51 -9

No files matched your search

+7 -1
View File
@@ -29,7 +29,13 @@ export default async function (context: InstancePluginContext) {
const onlinePlayers = new Set<string>();
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") {
+7 -1
View File
@@ -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
);
}
+16 -3
View File
@@ -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" });
+7 -1
View File
@@ -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
);
}
+14 -3
View File
@@ -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()));