Terminate control connections of deleted users

ControlConnection keeps the User object it authenticated with and checks
permissions against it. Deleting the user removed the record but left
its sessions open, so a deleted user kept all its permissions until the
WebSocket closed.

Repro: create "victim" with role 0 and connect as it. Another admin
sends UserDeleteRequest("victim"). Victim can still send
UserCreateRequest("mallory") and UserUpdateRolesRequest("mallory", [0]).

Terminate the deleted user's connections like revoke-token does. When
a user deletes itself, defer terminating the requesting connection so
the response is sent first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5 committed 2026-09-13 13:30:45 +00:00
1 parent 5b63fd27fe
commit fb71ee7282
2 files changed
+65 -1

No files matched your search

+54 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import events from "node:events";
import * as lib from "@clusterio/lib";
import { ControlConnection, ControllerHooks } from "@clusterio/controller";
import { MockConnector, MockLogger } from "../mock.js";
import { MockConnector, MockController, MockLogger } from "../mock.js";
const addr = lib.Address.fromShorthand;
@@ -91,4 +91,57 @@ describe("controller/src/ControlConnection", function() {
assert.equal(mockController.debugEvents.listenerCount("message"), 0);
});
});
describe(".handleUserDeleteRequest()", function() {
let mockController;
let nextId;
beforeEach(function() {
mockController = new MockController();
mockController.sendTo = () => {};
mockController.wsServer.controlConnections = new Map();
nextId = 1;
});
function connect(name) {
const connection = {
_controller: mockController,
user: mockController.users.getByName(name),
connector: {
terminated: false,
terminate() { this.terminated = true; },
},
};
mockController.wsServer.controlConnections.set(nextId, connection);
nextId += 1;
return connection;
}
async function deleteUser(connection, name) {
await ControlConnection.prototype.handleUserDeleteRequest.call(
connection, new lib.UserDeleteRequest(name)
);
}
it("terminates connections of the deleted user", async function() {
const admin = connect("test");
const player1 = connect("player");
const player2 = connect("player");
await deleteUser(admin, "player");
assert.equal(mockController.users.getByName("player"), undefined);
assert.equal(player1.connector.terminated, true);
assert.equal(player2.connector.terminated, true);
assert.equal(admin.connector.terminated, false);
});
it("terminates its own connection after the handler returns", async function() {
const player = connect("player");
await deleteUser(player, "player");
assert.equal(player.connector.terminated, false);
await new Promise(resolve => setImmediate(resolve));
assert.equal(player.connector.terminated, true);
});
});
});