From fb71ee72826443d3f58f33207268dc1294a41fd9 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:49:23 +0000 Subject: [PATCH 1/3] 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) --- packages/controller/src/ControlConnection.ts | 11 ++++ test/controller/ControlConnection.test.js | 55 +++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 60f3e263..79669e8d 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -1070,6 +1070,17 @@ export default class ControlConnection extends BaseConnection { } this._controller.users.deleteUser(user); + for (let controlConnection of this._controller.wsServer.controlConnections.values()) { + if (controlConnection.user.id !== user.id) { + continue; + } + if (controlConnection === this) { + // Let the response go out before dropping our own session + setImmediate(() => controlConnection.connector.terminate()); + } else { + controlConnection.connector.terminate(); + } + } if (user.isAdmin) { this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false)); diff --git a/test/controller/ControlConnection.test.js b/test/controller/ControlConnection.test.js index b97c5527..383eca96 100644 --- a/test/controller/ControlConnection.test.js +++ b/test/controller/ControlConnection.test.js @@ -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); + }); + }); }); From 1da7c1da672cf739171e39a7a2c538a5da5a86d5 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:31:09 +0000 Subject: [PATCH 2/3] Terminate deleted user sessions immediately Drop the special case that deferred terminating the connection issuing the delete, so deleting yourself behaves the same as revoke-token and every session of the deleted user is closed before the handler returns. Set tokenValidAfter to the current time in createUser instead of 0. Deleting a user and recreating it under the same name left tokens issued before the deletion valid, as the check is iat < tokenValidAfter. Doing it in createUser covers every path that makes a user. A token signed in the same second as creation is still accepted, so create-admin and the other production paths sign after the user exists and are unaffected. The integration harness signed the control token at import time, before bootstrap create-admin created the user. Sign it in the root before hook instead and reuse it in link.js. Co-Authored-By: Claude Opus 5 (1M context) --- packages/controller/src/ControlConnection.ts | 8 +------- packages/controller/src/UserManager.ts | 4 +++- test/controller/ControlConnection.test.js | 4 +--- test/controller/UserManager.test.js | 17 +++++++++++++++++ test/integration/index.js | 4 +++- test/integration/link.js | 9 +++++---- 6 files changed, 30 insertions(+), 16 deletions(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 79669e8d..b37aa37c 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -1071,13 +1071,7 @@ export default class ControlConnection extends BaseConnection { this._controller.users.deleteUser(user); for (let controlConnection of this._controller.wsServer.controlConnections.values()) { - if (controlConnection.user.id !== user.id) { - continue; - } - if (controlConnection === this) { - // Let the response go out before dropping our own session - setImmediate(() => controlConnection.connector.terminate()); - } else { + if (controlConnection.user.id === user.id) { controlConnection.connector.terminate(); } } diff --git a/packages/controller/src/UserManager.ts b/packages/controller/src/UserManager.ts index 60649e5f..9ef56d7c 100644 --- a/packages/controller/src/UserManager.ts +++ b/packages/controller/src/UserManager.ts @@ -119,7 +119,9 @@ export default class UserManager { roles.add(defaultRoleId); } - const user = new User(this.records, this._controllerRoles, 0, name, roles); + // Reject tokens issued before the user existed, e.g. for a previous user by the same name + const tokenValidAfter = Math.floor(Date.now() / 1000); + const user = new User(this.records, this._controllerRoles, tokenValidAfter, name, roles); this.records.set(user); return user; } diff --git a/test/controller/ControlConnection.test.js b/test/controller/ControlConnection.test.js index 383eca96..75832ca1 100644 --- a/test/controller/ControlConnection.test.js +++ b/test/controller/ControlConnection.test.js @@ -135,12 +135,10 @@ describe("controller/src/ControlConnection", function() { assert.equal(admin.connector.terminated, false); }); - it("terminates its own connection after the handler returns", async function() { + it("terminates its own connection when deleting itself", 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); }); }); diff --git a/test/controller/UserManager.test.js b/test/controller/UserManager.test.js index 5005875e..62ca5209 100644 --- a/test/controller/UserManager.test.js +++ b/test/controller/UserManager.test.js @@ -79,6 +79,23 @@ describe("controller/UserManager", function () { const user = userManager.createUser("Eve"); assert(user.roleIds.has(1)); }); + + it("should set tokenValidAfter to the current time", function () { + const before = Math.floor(Date.now() / 1000); + const user = userManager.createUser("Mallory"); + assert(user.tokenValidAfter >= before, "tokenValidAfter predates the call"); + assert(user.tokenValidAfter <= Math.floor(Date.now() / 1000), "tokenValidAfter is in the future"); + }); + + it("should invalidate tokens issued before a user of the same name was recreated", function () { + const user = userManager.createUser("Niaj"); + const oldTokenIat = user.tokenValidAfter - 60; + userManager.deleteUser(user); + + // WsServer.registerControl rejects the token when iat < tokenValidAfter + const recreated = userManager.createUser("Niaj"); + assert(oldTokenIat < recreated.tokenValidAfter, "token from before the deletion is still valid"); + }); }); describe(".getOrCreateUser()", function () { diff --git a/test/integration/index.js b/test/integration/index.js index c2816412..a1730c22 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -191,7 +191,8 @@ let haveFactorioInstall; const baseHostConfig = loadJSON("config-host.json"); export let url = "https://localhost:4443/"; -export let controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64")); +// Signed in the root before hook, as the user has to exist before the token is issued +export let controlToken; export let instancesDir = path.join("temp", "test", "instances"); export let modsDir = path.join("temp", "test", "mods"); export let databaseDir = path.join("temp", "test", "database"); @@ -373,6 +374,7 @@ before(async function() { console.log("Bootstrapping"); await execController("bootstrap create-admin test"); await execController("bootstrap create-ctl-config test"); + controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64")); controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/); diff --git a/test/integration/link.js b/test/integration/link.js index d45a28ca..53b3c76b 100644 --- a/test/integration/link.js +++ b/test/integration/link.js @@ -2,10 +2,11 @@ import assert from "node:assert/strict"; import events from "node:events"; import jwt from "jsonwebtoken"; -import { TestControlConnector, TestHostConnector, TestControl, get, exec, url, slowTest } from "./index.js"; +import { + TestControlConnector, TestHostConnector, TestControl, get, exec, url, controlToken, slowTest, +} from "./index.js"; import { ConnectionClosed, ProtocolError, PolicyViolation, AuthenticationFailed } from "@clusterio/lib"; -let token = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64")); let tokenHost = jwt.sign({ aud: "host", host: 0 }, Buffer.from("TestSecretDoNotUse", "base64")); describe("Integration of lib/link", function() { @@ -14,7 +15,7 @@ describe("Integration of lib/link", function() { let hostConnector; beforeEach(async function() { controlConnector = new TestControlConnector(url, 0.2); - controlConnector.token = token; + controlConnector.token = controlToken; control = new TestControl(controlConnector, false); hostConnector = new TestHostConnector(url, 0.2); hostConnector.token = tokenHost; @@ -100,7 +101,7 @@ describe("Integration of lib/link", function() { }); it("should refuse connection with incorrect audience", async function() { - hostConnector.token = token; + hostConnector.token = controlToken; await assert.rejects( hostConnector.connect(), new Error("Authentication failed: jwt audience invalid. expected: host or slave") From c9e8aeee5cd5f673d67da3c09607fa4d56ec7369 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:34:58 +0000 Subject: [PATCH 3/3] Handle SessionLost when deleting your own account Deleting your own account now terminates the session immediately, so the web UI received a SessionLost rejection and reported "Error deleting user" for a delete that had succeeded. Use the same branch revoke does. Co-Authored-By: Claude Opus 5 (1M context) --- packages/web_ui/src/components/UserViewPage.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx index fd6b2848..4a4bd97f 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -146,6 +146,13 @@ export default function UserViewPage() { new lib.UserDeleteRequest(userName) ).then(() => { navigate("/users"); + }, err => { + if (err instanceof lib.SessionLost && userName === account.name) { + // Got kicked out after deleting our own account + navigate("/users"); + return; + } + throw err; }).catch(notifyErrorHandler("Error deleting user")); }} >