Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
This commit is contained in:
Andras Bacsai
2026-11-06 14:25:02 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 peaklabs-dev Aditya Tripathi
parent b7dc2e435b
commit 033f0c84da
581 changed files with 58626 additions and 2456 deletions
+190 -16
View File
@@ -4,6 +4,9 @@ import pty from 'node-pty';
import 'dotenv/config';
import {
TERMINAL_CLOSE_CODES,
TERMINAL_IDLE_TIMEOUT_SECONDS,
TERMINAL_SESSION_CHECK_INTERVAL_MS,
TERMINAL_SESSION_CHECK_MAX_FAILURES,
authenticateTerminalUpgrade,
createTerminalUpgradeHandler,
extractHereDocContent,
@@ -18,6 +21,7 @@ import {
isAuthorizedTargetHost,
rejectTerminalSocket,
sanitizeSshArgs,
terminalSessionCheckResult,
validateSshArgs,
} from './terminal-utils.js';
@@ -55,6 +59,9 @@ async function postToCoolify(path, headers, body = null) {
});
});
request.setTimeout(10_000, () => {
request.destroy(new Error('Request to Coolify timed out'));
});
request.on('error', reject);
request.end(requestBody);
});
@@ -259,21 +266,26 @@ wss.on('close', () => clearInterval(heartbeat));
const messageHandlers = {
message: (session, data) => {
if (typeof data !== 'string') {
return;
}
session.lastActivityAt = Date.now();
session.ptyProcess.write(data);
},
resize: (session, { cols, rows }) => {
cols = cols > 0 ? cols : 80;
rows = rows > 0 ? rows : 30;
session.ptyProcess.resize(cols, rows)
resize: (session, size) => {
const cols = terminalDimension(size?.cols, 80);
const rows = terminalDimension(size?.rows, 30);
session.ptyProcess.resize(cols, rows);
},
pause: (session) => session.ptyProcess.pause(),
resume: (session) => session.ptyProcess.resume(),
ping: (session) => session.ws.send('pong'),
checkActive: (session, data) => {
if (data === 'force' && session.isActive) {
session.endReason = 'replaced';
killPtyProcess(session.userId);
} else {
session.ws.send(session.isActive);
session.ws.send(String(session.isActive));
}
},
terminalToken: (session, token) => handleTerminalToken(session, token)
@@ -289,10 +301,17 @@ function handleMessage(userSession, message) {
return;
}
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
logTerminal('warn', 'Ignoring websocket message because it is not a JSON object.', {
userId: userSession.userId,
});
return;
}
Object.entries(parsed).forEach(([key, value]) => {
const handler = messageHandlers[key];
const handler = Object.hasOwn(messageHandlers, key) ? messageHandlers[key] : null;
if (handler && (userSession.isActive || key === 'checkActive' || key === 'terminalToken' || key === 'ping')) {
handler(userSession, value);
runMessageHandler(handler, userSession, key, value);
} else if (!handler) {
logTerminal('warn', 'Ignoring websocket message with unknown handler key.', {
userId: userSession.userId,
@@ -307,6 +326,28 @@ function handleMessage(userSession, message) {
});
}
/**
* Run one handler. A handler that throws must not stop the process, because one
* process serves the terminals of all users.
*/
function runMessageHandler(handler, userSession, key, value) {
const logFailure = (error) => logTerminal('error', 'Websocket message handler failed.', {
userId: userSession.userId,
key,
error: error?.message ?? error,
});
try {
Promise.resolve(handler(userSession, value)).catch(logFailure);
} catch (error) {
logFailure(error);
}
}
function terminalDimension(value, fallback) {
return Number.isInteger(value) && value > 0 && value <= 1000 ? value : fallback;
}
function parseMessage(message) {
try {
return JSON.parse(message);
@@ -318,9 +359,15 @@ function parseMessage(message) {
}
}
async function handleCommand(ws, command, userId) {
/**
* Starts the PTY for a redeemed terminal session.
*
* @returns {Promise<boolean>} false when the command was rejected and no PTY started
*/
async function handleCommand(ws, command, userId, terminalSessionId, reportEnd) {
const userSession = userSessions.get(userId);
if (userSession && userSession.isActive) {
userSession.endReason = 'replaced';
const result = await killPtyProcess(userId);
if (!result) {
logTerminal('warn', 'Rejecting new terminal command because the previous PTY could not be terminated.', {
@@ -328,7 +375,7 @@ async function handleCommand(ws, command, userId) {
});
// if terminal is still active, even after we tried to kill it, dont continue and show error
ws.send('unprocessable');
return;
return false;
}
}
@@ -360,7 +407,7 @@ async function handleCommand(ws, command, userId) {
sshArgs,
});
ws.send('Invalid SSH command: No target host found');
return;
return false;
}
// Validate target host against authorized IPs
@@ -371,7 +418,7 @@ async function handleCommand(ws, command, userId) {
authorizedIPs: userSession.authorizedIPs,
});
ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`);
return;
return false;
}
if (!validateSshArgs(sshArgs, userSession.authorizedIPs)) {
@@ -380,7 +427,7 @@ async function handleCommand(ws, command, userId) {
targetHost,
});
ws.send('Invalid SSH command: Unsupported SSH arguments');
return;
return false;
}
const sanitizedSshArgs = sanitizeSshArgs(sshArgs);
@@ -405,14 +452,21 @@ async function handleCommand(ws, command, userId) {
userSession.ptyProcess = ptyProcess;
userSession.isActive = true;
userSession.endReason = null;
userSession.lastActivityAt = Date.now();
const sessionWatch = watchTerminalSession(userSession, ptyProcess, terminalSessionId);
// Attach sessions share the container's main process, so they must be detached, not killed.
userSession.isAttach = isAttachCommand(hereDocContent);
ws.send('pty-ready');
// PTY output goes in binary frames. Text frames are only control messages of this server,
// so output of the remote shell cannot look like one.
ptyProcess.onData((data) => {
if (userSession.ptyProcess !== ptyProcess) return;
ws.send(data);
// Output counts as activity, so commands like `docker logs -f` are not ended as idle.
userSession.lastActivityAt = Date.now();
ws.send(Buffer.from(data, 'utf8'));
});
// when parent closes
@@ -423,13 +477,18 @@ async function handleCommand(ws, command, userId) {
signal,
});
clearInterval(sessionWatch);
// A previous PTY can exit late (e.g. an attach session detaching during a mode
// switch) after its replacement is already spawned. Only mutate the shared session
// state when this exit belongs to the session's current PTY.
if (userSession.ptyProcess !== ptyProcess) {
reportEnd('replaced');
return;
}
reportEnd(userSession.endReason ?? 'exited');
ws.send('pty-exited');
userSession.isActive = false;
@@ -440,8 +499,101 @@ async function handleCommand(ws, command, userId) {
});
userSession.terminalSessionTimer = setTimeout(async () => {
await killPtyProcess(userId);
await endTerminalSession(userSession, ptyProcess, 'max_duration', `it reached the maximum duration of ${terminalSessionTimeout / 3600} hours.`);
}, terminalSessionTimeout * 1000);
return true;
}
/**
* Ends the PTY when it has no input and no output for too long, or when Coolify no longer allows it:
* after a logout, a lost admin role, a removal from the team, or when terminal access
* was turned off on the server.
*/
function watchTerminalSession(userSession, ptyProcess, terminalSessionId) {
let failedChecks = 0;
let checking = false;
return setInterval(async () => {
if (userSession.ptyProcess !== ptyProcess) {
return;
}
if (TERMINAL_IDLE_TIMEOUT_SECONDS > 0 && Date.now() - userSession.lastActivityAt >= TERMINAL_IDLE_TIMEOUT_SECONDS * 1000) {
await endTerminalSession(userSession, ptyProcess, 'idle', `no input or output for ${TERMINAL_IDLE_TIMEOUT_SECONDS / 60} minutes.`);
return;
}
if (checking) {
return;
}
checking = true;
let response = null;
try {
// Accept JSON so a logged-out session gets a 401 instead of a redirect to the login page.
response = await postToCoolify('/terminal/session/check', { ...userSession.authHeaders, Accept: 'application/json' }, { session_id: terminalSessionId });
} catch (error) {
logTerminal('error', 'Failed to check terminal session.', {
userId: userSession.userId,
error: error.message,
});
}
checking = false;
const result = terminalSessionCheckResult(response);
if (result === 'active') {
failedChecks = 0;
return;
}
if (result === 'revoked') {
await endTerminalSession(userSession, ptyProcess, 'revoked', 'access to this terminal was revoked.');
return;
}
// Coolify is unavailable (for example during an upgrade) or gave an unexpected answer. Fail closed after a few minutes.
logTerminal('warn', 'Terminal session check failed.', {
userId: userSession.userId,
status: response?.status,
});
failedChecks++;
if (failedChecks >= TERMINAL_SESSION_CHECK_MAX_FAILURES) {
await endTerminalSession(userSession, ptyProcess, 'revoked', 'Coolify could not verify access to this terminal.');
}
}, TERMINAL_SESSION_CHECK_INTERVAL_MS);
}
async function endTerminalSession(userSession, ptyProcess, reason, notice) {
if (!userSession.isActive || userSession.ptyProcess !== ptyProcess) {
return;
}
logTerminal('log', 'Ending terminal session.', {
userId: userSession.userId,
reason,
});
userSession.endReason = reason;
if (userSession.ws.readyState === userSession.ws.OPEN) {
userSession.ws.send(`Terminal session ended: ${notice}`);
}
await killPtyProcess(userSession.userId);
}
// Sent without the browser cookie: the session id authenticates it, and the user may have logged out.
function reportTerminalSessionEnd(terminalSessionId, reason) {
postToCoolify('/terminal/session/end', {}, { session_id: terminalSessionId, reason })
.then((response) => {
if (response.status !== 204) {
throw createHttpError(response);
}
})
.catch((error) => {
logTerminal('error', 'Failed to report the end of a terminal session.', {
reason,
error: error.message,
});
});
}
function rejectTerminalToken(userSession, message) {
@@ -458,15 +610,28 @@ async function handleTerminalToken(userSession, token) {
return;
}
let terminalSessionId;
let endReported = false;
const reportEnd = (reason) => {
if (typeof terminalSessionId !== 'string' || endReported) return;
endReported = true;
reportTerminalSessionEnd(terminalSessionId, reason);
};
try {
const response = await postToCoolify('/terminal/session', userSession.authHeaders, { token });
if (response.status !== 200 || typeof response.data?.command !== 'string') {
terminalSessionId = response.data?.session_id;
if (response.status !== 200 || typeof response.data?.command !== 'string' || typeof terminalSessionId !== 'string') {
rejectTerminalToken(userSession, 'Unauthorized: Terminal token was rejected');
return;
}
await handleCommand(userSession.ws, [response.data.command], userSession.userId);
const started = await handleCommand(userSession.ws, [response.data.command], userSession.userId, terminalSessionId, reportEnd);
if (!started) {
reportEnd('rejected');
}
} catch (error) {
reportEnd('rejected');
logTerminal('error', 'Failed to redeem terminal token.', {
userId: userSession.userId,
error: error.message,
@@ -480,6 +645,7 @@ async function handleError(err, userId) {
userId,
error: err?.message ?? err,
});
markDisconnected(userId);
await killPtyProcess(userId);
}
@@ -487,10 +653,18 @@ async function handleClose(userId) {
logTerminal('log', 'Cleaning up terminal websocket session.', {
userId,
});
markDisconnected(userId);
await killPtyProcess(userId);
userSessions.delete(userId);
}
function markDisconnected(userId) {
const session = userSessions.get(userId);
if (session?.isActive) {
session.endReason = 'disconnected';
}
}
async function killPtyProcess(userId) {
const session = userSessions.get(userId);
if (!session?.ptyProcess) return false;
@@ -0,0 +1,176 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import * as terminalUtils from './terminal-utils.js';
const source = readFileSync(new URL('./terminal-server.js', import.meta.url), 'utf8');
const handler = source.slice(source.indexOf('async function handleTerminalToken('), source.indexOf('async function handleError('));
const commandHandler = source.slice(source.indexOf('async function handleCommand('), source.indexOf('function watchTerminalSession('));
const sessionWatcher = source.slice(source.indexOf('function watchTerminalSession('), source.indexOf('async function endTerminalSession('));
for (const exitDuringStartup of [false, true]) {
test(`startup failure reports one end (exit during startup: ${exitDuringStartup})`, async () => {
const reports = [];
let exit;
const context = vm.createContext({
postToCoolify: async () => ({ status: 200, data: { command: 'ssh', session_id: 'session' } }),
handleCommand: async (_ws, _command, _userId, _id, reportEnd) => {
exit = () => reportEnd('exited');
if (exitDuringStartup) exit();
throw new Error('Startup failed');
},
reportTerminalSessionEnd: (id, reason) => reports.push({ id, reason }),
logTerminal() {},
rejectTerminalToken() {},
});
vm.runInContext(handler, context);
await context.handleTerminalToken({ ws: {}, userId: 'user' }, 'a'.repeat(64));
if (!exitDuringStartup) exit();
assert.deepEqual(reports, [{ id: 'session', reason: exitDuringStartup ? 'exited' : 'rejected' }]);
});
}
/**
* Runs the session watcher with the given check responses, one per interval tick.
* Returns the end reasons, in order.
*/
async function runSessionChecks(responses) {
const ends = [];
let tick;
const ptyProcess = {};
const userSession = { userId: 'user', ptyProcess, lastActivityAt: Date.now(), authHeaders: {} };
const context = vm.createContext({
...terminalUtils,
Date,
setInterval: (callback) => { tick = callback; return 1; },
postToCoolify: async () => {
const response = responses.shift();
if (response instanceof Error) throw response;
return response;
},
endTerminalSession: async (_session, _pty, reason) => ends.push(reason),
logTerminal() {},
});
vm.runInContext(sessionWatcher, context);
context.watchTerminalSession(userSession, ptyProcess, 'session');
while (responses.length > 0) {
await tick();
}
return ends;
}
for (const status of [401, 403, 404, 419]) {
test(`a session check with status ${status} revokes the session at once`, async () => {
assert.deepEqual(await runSessionChecks([{ status, data: null }]), ['revoked']);
});
}
test('a redirect from a session check counts as a failed check, not a revocation', async () => {
const redirect = { status: 302, data: null };
assert.deepEqual(await runSessionChecks([redirect, redirect]), []);
assert.deepEqual(await runSessionChecks([redirect, redirect, redirect]), ['revoked']);
});
test('an active check resets the failed checks', async () => {
const failed = { status: 302, data: null };
const active = { status: 200, data: { active: true } };
assert.deepEqual(await runSessionChecks([failed, failed, active, failed, new Error('down')]), []);
});
test('a 200 response without the active flag is a failed check', async () => {
const unexpected = { status: 200, data: '<html></html>' };
assert.deepEqual(await runSessionChecks([unexpected, unexpected, unexpected]), ['revoked']);
});
test('pty output is sent as binary frames, so it cannot look like a control message', async () => {
const sent = [];
let emitData;
const ptyProcess = { onData: (callback) => { emitData = callback; }, onExit() {} };
const userSession = { userId: 'user', isActive: false, authorizedIPs: ['10.0.0.5'], terminalSessionTimer: null };
const context = vm.createContext({
...terminalUtils,
process,
Buffer,
userSessions: new Map([['user', userSession]]),
pty: { spawn: () => ptyProcess },
watchTerminalSession: () => 1,
setTimeout: () => 1,
logTerminal() {},
});
vm.runInContext(commandHandler, context);
const command = "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p '22' -- 'root'@'10.0.0.5' 'bash -se' << \\$abc\necho hi\nabc";
const started = await context.handleCommand({ send: (data) => sent.push(data) }, [command], 'user', 'session', () => {});
assert.equal(started, true, JSON.stringify(sent));
emitData('Terminal session ended: spoofed');
assert.deepEqual(sent.slice(0, 1), ['pty-ready']);
assert.ok(Buffer.isBuffer(sent[1]));
assert.equal(sent[1].toString('utf8'), 'Terminal session ended: spoofed');
});
/**
* Starts a PTY with a fake clock and returns controls to emit output, send input, and run a watcher tick.
*/
async function startWatchedSession() {
let now = 0;
let tick;
let emitData;
const ends = [];
const ptyProcess = { onData: (callback) => { emitData = callback; }, onExit() {}, write() {} };
const userSession = { userId: 'user', isActive: false, authorizedIPs: ['10.0.0.5'], terminalSessionTimer: null, authHeaders: {} };
const context = vm.createContext({
...terminalUtils,
process,
Buffer,
Date: { now: () => now },
userSessions: new Map([['user', userSession]]),
pty: { spawn: () => ptyProcess },
setInterval: (callback) => { tick = callback; return 1; },
setTimeout: () => 1,
postToCoolify: async () => ({ status: 200, data: { active: true } }),
endTerminalSession: async (_session, _pty, reason) => ends.push(reason),
logTerminal() {},
});
vm.runInContext(`${commandHandler}\n${sessionWatcher}\n${source.slice(source.indexOf('const messageHandlers = {'), source.indexOf('function handleMessage('))}\nthis.messageHandlers = messageHandlers;`, context);
const command = "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p '22' -- 'root'@'10.0.0.5' 'bash -se' << \\$abc\necho hi\nabc";
assert.equal(await context.handleCommand({ send() {} }, [command], 'user', 'session', () => {}), true);
return {
ends,
at: async (minutes, action) => {
now = minutes * 60 * 1000;
await action?.();
},
output: () => emitData('tail -f line\r\n'),
input: () => context.messageHandlers.message(userSession, 'x'),
tick: () => tick(),
};
}
test('pty output keeps a session without input alive', async () => {
const session = await startWatchedSession();
await session.at(25, session.output);
await session.at(50, session.output);
await session.at(70, session.tick);
assert.deepEqual(session.ends, []);
});
test('input keeps a session without output alive', async () => {
const session = await startWatchedSession();
await session.at(25, session.input);
await session.at(50, session.tick);
assert.deepEqual(session.ends, []);
});
test('a session with no input and no output is not ended as idle by default', async () => {
const session = await startWatchedSession();
await session.at(10, session.output);
await session.at(7 * 60, session.tick);
assert.deepEqual(session.ends, []);
});
@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(new URL('./terminal-server.js', import.meta.url), 'utf8');
const extract = (name, next, globals) => vm.runInNewContext(`${source.slice(source.indexOf(name), source.lastIndexOf('\n', source.indexOf(next)))}; ${name.match(/function (\w+)/)[1]}`, globals);
test('requests destroy and reject on timeout', async () => {
let expire, fail;
const post = extract('async function postToCoolify', 'function parseResponseData', {
process, Buffer, http: { request: () => ({
setTimeout(ms, callback) { assert.equal(ms, 10000); expire = callback; },
on(event, callback) { if (event === 'error') fail = callback; },
end() {}, destroy(error) { fail(error); },
}) },
});
const pending = post('/check', {});
expire();
await assert.rejects(pending, /timed out/);
});
test('idle check continues during pending access request', async () => {
let tick, now = 0, ended = false;
const watch = extract('function watchTerminalSession', 'function endTerminalSession', {
setInterval(callback) { tick = callback; }, Date: { now: () => now },
TERMINAL_IDLE_TIMEOUT_SECONDS: 60, TERMINAL_SESSION_CHECK_INTERVAL_MS: 1000,
postToCoolify: () => new Promise(() => {}),
endTerminalSession: async () => { ended = true; },
});
const pty = {};
watch({ ptyProcess: pty, lastActivityAt: 0 }, pty, 'id');
tick(); now = 60000; await tick();
assert.equal(ended, true);
});
+77 -16
View File
@@ -2,6 +2,36 @@ import { parseCookie } from 'cookie';
export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60;
/**
* A PTY with no input and no output for this long is ended. 0 turns the idle limit off, so a
* long command without output (for example a restore) keeps running until the maximum duration.
*/
export const TERMINAL_IDLE_TIMEOUT_SECONDS = 0;
/** How often each live PTY checks with Coolify that the user may still use it. */
export const TERMINAL_SESSION_CHECK_INTERVAL_MS = 60 * 1000;
/** Consecutive failed checks (Coolify unreachable, 5xx, or an unexpected answer) that end a PTY. */
export const TERMINAL_SESSION_CHECK_MAX_FAILURES = 3;
/** Session check statuses with which Coolify says that the session is no longer allowed. */
const TERMINAL_SESSION_REVOKED_STATUSES = new Set([401, 403, 404, 419]);
/**
* Reads the answer of Coolify to a session check. Only an explicit rejection revokes the
* session at once. Anything else, for example a redirect from a page gate, is a failed check.
*
* @param {{ status: number, data: unknown } | null} response null when the request failed
* @returns {'active' | 'revoked' | 'failed'}
*/
export function terminalSessionCheckResult(response) {
if (response?.status === 200 && response.data?.active === true) {
return 'active';
}
return TERMINAL_SESSION_REVOKED_STATUSES.has(response?.status) ? 'revoked' : 'failed';
}
/**
* WebSocket close codes sent to the browser. Browsers cannot read the HTTP
* status of a rejected WebSocket handshake, so rejections are reported with
@@ -243,13 +273,8 @@ export function extractSshArgs(commandString) {
}
sshArgs = sshArgs.map((arg) => normalizeShellArgument(arg));
sshArgs = sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
if (!sshArgs.includes('RequestTTY=yes') && !sshArgs.some(arg => arg.includes('RequestTTY='))) {
sshArgs.push('-o', 'RequestTTY=yes');
}
return sshArgs;
return sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
}
export function extractHereDocContent(commandString) {
@@ -321,6 +346,9 @@ const REQUIRED_SSH_OPTIONS = new Set([
'StrictHostKeyChecking',
'UserKnownHostsFile',
'PasswordAuthentication',
'IdentitiesOnly',
'ClearAllForwardings',
'ForwardAgent',
'ConnectTimeout',
'ServerAliveInterval',
'RequestTTY',
@@ -329,9 +357,10 @@ const REQUIRED_SSH_OPTIONS = new Set([
function isAllowedSshOption(name, value) {
const fixedOptions = {
StrictHostKeyChecking: 'no',
UserKnownHostsFile: '/dev/null',
PasswordAuthentication: 'no',
IdentitiesOnly: 'yes',
ClearAllForwardings: 'yes',
ForwardAgent: 'no',
LogLevel: 'ERROR',
ControlMaster: 'auto',
ProxyCommand: 'cloudflared access ssh --hostname %h',
@@ -341,6 +370,18 @@ function isAllowedSshOption(name, value) {
return value === fixedOptions[name];
}
if (name === 'StrictHostKeyChecking') {
return value === 'yes' || value === 'no';
}
if (name === 'UserKnownHostsFile') {
return value === '/dev/null' || /^\/var\/www\/html\/storage\/app\/ssh\/known_hosts\/known_hosts_[a-zA-Z0-9_-]+$/.test(value);
}
if (name === 'HostKeyAlias') {
return /^coolify-[a-zA-Z0-9_-]+$/.test(value);
}
if (name === 'RequestTTY') {
return value === 'yes' || value === 'no';
}
@@ -364,6 +405,8 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
const seenOptions = new Set();
let hasIdentityFile = false;
let hasPort = false;
let hasConfigFile = false;
let hasEndOfOptions = false;
let targetHost = null;
for (let index = 0; index < sshArgs.length; index++) {
@@ -373,6 +416,18 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
return false;
}
// ssh reads every argument after `--` as the destination or the remote command.
if (hasEndOfOptions) {
if (targetHost !== null || !/^[a-zA-Z0-9_.][a-zA-Z0-9._-]*@[^@]+$/.test(argument)) {
return false;
}
targetHost = extractTargetHost([argument]);
if (!/^(?:[a-zA-Z0-9]|\[)/.test(targetHost ?? '')) {
return false;
}
continue;
}
if (argument === '-i') {
const identityFile = sshArgs[++index];
if (hasIdentityFile || !/^\/var\/www\/html\/storage\/app\/ssh\/keys\/ssh_key@[a-zA-Z0-9_-]+$/.test(identityFile ?? '')) {
@@ -391,6 +446,19 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
continue;
}
if (argument === '-F') {
if (hasConfigFile || sshArgs[++index] !== '/dev/null') {
return false;
}
hasConfigFile = true;
continue;
}
if (argument === '--') {
hasEndOfOptions = true;
continue;
}
if (argument === '-o') {
const option = sshArgs[++index];
const separator = option?.indexOf('=') ?? -1;
@@ -407,14 +475,6 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
continue;
}
if (/^[a-zA-Z0-9_][a-zA-Z0-9._-]*@[^@]+$/.test(argument) && targetHost === null) {
targetHost = extractTargetHost([argument]);
if (!/^(?:[a-zA-Z0-9]|\[)/.test(targetHost ?? '')) {
return false;
}
continue;
}
return false;
}
@@ -425,6 +485,7 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) {
return hasIdentityFile
&& hasPort
&& hasConfigFile
&& targetHost !== null
&& hasRequiredOptions
&& hasCompleteMultiplexingOptions
+40 -7
View File
@@ -127,7 +127,7 @@ test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () =>
test('validateSshArgs accepts the SSH arguments generated by Coolify', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p '22' 'root'@'10.0.0.5' 'bash -se' << \\$abc\necho hi\nabc"
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p '22' -- 'root'@'10.0.0.5' 'bash -se' << \\$abc\necho hi\nabc"
);
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true);
@@ -143,7 +143,7 @@ test('validateSshArgs rejects an injected ProxyCommand', () => {
test('validateSshArgs accepts only the fixed Cloudflare ProxyCommand', () => {
const validArgs = extractSshArgs(
"timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@example.com 'bash -se' << \\$abc\necho hi\nabc"
"timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p 22 -- root@example.com 'bash -se' << \\$abc\necho hi\nabc"
);
const maliciousArgs = [...validArgs];
maliciousArgs[1] = 'ProxyCommand=cloudflared access ssh --hostname %h; id';
@@ -154,7 +154,7 @@ test('validateSshArgs accepts only the fixed Cloudflare ProxyCommand', () => {
test('validateSshArgs rejects unknown SSH options and key paths', () => {
const baseArgs = extractSshArgs(
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p 22 -- root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
);
assert.equal(validateSshArgs(['-F', '/tmp/config', ...baseArgs], ['10.0.0.5']), false);
@@ -168,18 +168,42 @@ test('validateSshArgs rejects unknown SSH options and key paths', () => {
test('validateSshArgs rejects repeated flags and option-like connection values', () => {
const baseArgs = extractSshArgs(
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p 22 -- root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
);
assert.equal(validateSshArgs(['-i', baseArgs[1], ...baseArgs], ['10.0.0.5']), false);
assert.equal(validateSshArgs([...baseArgs.slice(0, -1), '-p', '22', 'root@10.0.0.5'], ['10.0.0.5']), false);
const endOfOptions = baseArgs.indexOf('--');
assert.equal(validateSshArgs([...baseArgs.slice(0, endOfOptions), '-p', '22', ...baseArgs.slice(endOfOptions)], ['10.0.0.5']), false);
assert.equal(validateSshArgs([...baseArgs.slice(0, -1), 'root@-oProxyCommand=id'], ['-oproxycommand=id']), false);
assert.equal(validateSshArgs([...baseArgs.slice(0, -1), '-root@10.0.0.5'], ['10.0.0.5']), false);
});
test('validateSshArgs requires one destination directly after the end of options', () => {
const baseArgs = extractSshArgs(
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p 22 -- root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
);
const endOfOptions = baseArgs.indexOf('--');
const configFile = baseArgs.indexOf('-F');
assert.equal(validateSshArgs(baseArgs, ['10.0.0.5']), true);
assert.equal(validateSshArgs(baseArgs.filter((arg) => arg !== '--'), ['10.0.0.5']), false);
assert.equal(validateSshArgs([...baseArgs.slice(0, configFile), ...baseArgs.slice(configFile + 2)], ['10.0.0.5']), false);
assert.equal(validateSshArgs([...baseArgs.slice(0, endOfOptions), '--', ...baseArgs.slice(endOfOptions)], ['10.0.0.5']), false);
assert.equal(validateSshArgs([...baseArgs, 'id'], ['10.0.0.5']), false);
assert.equal(validateSshArgs(baseArgs.slice(0, -1), ['10.0.0.5']), false);
});
test('extractSshArgs adds no argument after the destination', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -p 22 -- root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
);
assert.deepEqual(sshArgs, ['-p', '22', '--', 'root@10.0.0.5']);
});
test('validateSshArgs rejects a destination that begins with an option prefix', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 -evil@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
"timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p 22 -- -evil@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
);
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false);
@@ -187,7 +211,7 @@ test('validateSshArgs rejects a destination that begins with an option prefix',
test('sanitizeSshArgs removes SSH multiplexing options before spawning SSH', () => {
const sshArgs = extractSshArgs(
"timeout 3600 ssh -o ControlMaster=auto -o ControlPath=/var/www/html/storage/app/ssh/mux/mux_cm123 -o ControlPersist=3600 -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
"timeout 3600 ssh -o ControlMaster=auto -o ControlPath=/var/www/html/storage/app/ssh/mux/mux_cm123 -o ControlPersist=3600 -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -F /dev/null -o IdentitiesOnly=yes -o ClearAllForwardings=yes -o ForwardAgent=no -o RequestTTY=no -o LogLevel=ERROR -p 22 -- root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc"
);
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true);
@@ -204,12 +228,21 @@ test('sanitizeSshArgs removes SSH multiplexing options before spawning SSH', ()
'ConnectTimeout=10',
'-o',
'ServerAliveInterval=20',
'-F',
'/dev/null',
'-o',
'IdentitiesOnly=yes',
'-o',
'ClearAllForwardings=yes',
'-o',
'ForwardAgent=no',
'-o',
'RequestTTY=yes',
'-o',
'LogLevel=ERROR',
'-p',
'22',
'--',
'root@10.0.0.5',
]);
});