fix(terminal): enforce eight hour session expiry

Add a visible countdown in the terminal UI and terminate realtime PTY
sessions after the fixed maximum lifetime.
This commit is contained in:
Andras Bacsai
2026-06-01 09:45:56 +02:00
parent 4d3182c938
commit 53f24df0a0
7 changed files with 156 additions and 8 deletions
+26 -8
View File
@@ -8,6 +8,7 @@ import {
extractSshArgs,
extractTargetHost,
extractTimeout,
getTerminalSessionTimeout,
isAuthorizedTargetHost,
} from './terminal-utils.js';
@@ -171,6 +172,7 @@ wss.on('connection', async (ws, req) => {
authorizedIPs: [],
authReady: false,
pendingMessages: [],
terminalSessionTimer: null,
};
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
@@ -340,8 +342,14 @@ async function handleCommand(ws, command, userId) {
}
}
if (userSession.terminalSessionTimer) {
clearTimeout(userSession.terminalSessionTimer);
userSession.terminalSessionTimer = null;
}
const commandString = command[0].split('\n').join(' ');
const timeout = extractTimeout(commandString);
const commandTimeout = extractTimeout(commandString);
const terminalSessionTimeout = getTerminalSessionTimeout();
const sshArgs = extractSshArgs(commandString);
const hereDocContent = extractHereDocContent(commandString);
@@ -350,7 +358,8 @@ async function handleCommand(ws, command, userId) {
logTerminal('log', 'Parsed terminal command metadata.', {
userId,
targetHost,
timeout,
commandTimeout,
terminalSessionTimeout,
sshArgs,
authorizedIPs: userSession?.authorizedIPs ?? [],
});
@@ -389,7 +398,8 @@ async function handleCommand(ws, command, userId) {
logTerminal('log', 'Spawning PTY process for terminal session.', {
userId,
targetHost,
timeout,
commandTimeout,
terminalSessionTimeout,
});
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
@@ -411,13 +421,16 @@ async function handleCommand(ws, command, userId) {
});
ws.send('pty-exited');
userSession.isActive = false;
if (userSession.terminalSessionTimer) {
clearTimeout(userSession.terminalSessionTimer);
userSession.terminalSessionTimer = null;
}
});
if (timeout) {
setTimeout(async () => {
await killPtyProcess(userId);
}, timeout * 1000);
}
userSession.terminalSessionTimer = setTimeout(async () => {
await killPtyProcess(userId);
}, terminalSessionTimeout * 1000);
}
async function handleError(err, userId) {
@@ -459,6 +472,11 @@ async function killPtyProcess(userId) {
setTimeout(() => {
if (!session.isActive || !session.ptyProcess) {
if (session.terminalSessionTimer) {
clearTimeout(session.terminalSessionTimer);
session.terminalSessionTimer = null;
}
logTerminal('log', 'PTY process terminated successfully.', {
userId,
killAttempts,
@@ -1,3 +1,9 @@
export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60;
export function getTerminalSessionTimeout() {
return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS;
}
export function extractTimeout(commandString) {
const timeoutMatch = commandString.match(/timeout (\d+)/);
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
MAX_TERMINAL_SESSION_TIMEOUT_SECONDS,
extractSshArgs,
extractTargetHost,
getTerminalSessionTimeout,
isAuthorizedTargetHost,
normalizeHostForAuthorization,
} from './terminal-utils.js';
@@ -45,3 +47,10 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
});
test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => {
assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
});