Files
033f0c84da v4.7 (#12478)
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>
2026-11-06 14:25:02 +01:00

563 lines
18 KiB
JavaScript

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
* application close codes (4000-4999) that the client can act on.
*/
export const TERMINAL_CLOSE_CODES = Object.freeze({
AUTH_REJECTED: 4401,
TOKEN_REJECTED: 4403,
AUTH_UNAVAILABLE: 1011,
});
export const TERMINAL_REJECTED_SOCKET_TERMINATE_MS = 2000;
/**
* Limits the Coolify requests that one WebSocket upgrade can cause.
*/
export const MAX_XSRF_TOKEN_CANDIDATES = 5;
/**
* Builds the header sets that can authenticate terminal requests to Coolify, one
* for each XSRF-TOKEN cookie. The whole browser Cookie header is forwarded, so
* Laravel finds its session cookie by its own name (SESSION_COOKIE or the slug
* of APP_NAME).
*
* The browser sends more than one XSRF-TOKEN cookie when a Laravel app on a
* parent domain also sets one (for example SESSION_DOMAIN=.example.com). Their
* order in the header does not show which one is Coolify's, so the caller must
* try each one.
*
* @param {string | undefined} cookieHeader
* @returns {Array<{ 'Cookie': string, 'X-XSRF-TOKEN': string }>} empty when a required value is missing
*/
export function getTerminalAuthHeaders(cookieHeader) {
if (!cookieHeader) {
return [];
}
// parseCookie() keeps only the first cookie of a name, so parse each pair alone.
const xsrfTokens = cookieHeader
.split(';')
.map((pair) => parseCookie(pair)['XSRF-TOKEN'])
.filter(Boolean);
return [...new Set(xsrfTokens)]
.slice(0, MAX_XSRF_TOKEN_CANDIDATES)
.map((xsrfToken) => ({
'Cookie': cookieHeader,
'X-XSRF-TOKEN': xsrfToken,
}));
}
/**
* Authenticates a terminal WebSocket upgrade with the browser's Laravel session
* cookie and XSRF token. Returns the headers that Coolify accepted, for the
* requests that follow on the same connection.
*
* @param {Array<{ 'Cookie': string, 'X-XSRF-TOKEN': string }>} authHeaderCandidates from getTerminalAuthHeaders()
* @param {(path: string, headers: object) => Promise<{ status: number }>} postToCoolify
* @returns {Promise<{ authenticated: true, authHeaders: object } | { authenticated: false, closeCode: number, reason: string, status?: number, error?: Error }>}
*/
export async function authenticateTerminalUpgrade(authHeaderCandidates, postToCoolify) {
if (authHeaderCandidates.length === 0) {
return {
authenticated: false,
closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED,
reason: 'Unauthorized: Missing required tokens',
};
}
let status;
try {
for (const authHeaders of authHeaderCandidates) {
const response = await postToCoolify('/terminal/auth', authHeaders);
if (response.status === 200) {
return { authenticated: true, authHeaders };
}
status = response.status;
}
} catch (error) {
return {
authenticated: false,
closeCode: TERMINAL_CLOSE_CODES.AUTH_UNAVAILABLE,
reason: 'Internal Server Error',
error,
};
}
return {
authenticated: false,
closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED,
reason: 'Unauthorized: Invalid credentials',
status,
};
}
/**
* Closes a WebSocket that must not be used. The socket is terminated if the
* peer does not complete the close handshake quickly.
*/
export function rejectTerminalSocket(ws, closeCode, reason, { terminateAfterMs = TERMINAL_REJECTED_SOCKET_TERMINATE_MS } = {}) {
const terminateTimer = setTimeout(() => ws.terminate(), terminateAfterMs);
terminateTimer.unref?.();
ws.once('close', () => clearTimeout(terminateTimer));
// Rejected sockets have no other listeners; an unhandled 'error' (e.g. a
// malformed frame from an unauthenticated client) would crash the server.
ws.on('error', () => ws.terminate());
ws.close(closeCode, reason);
}
/**
* Builds the HTTP `upgrade` listener for the terminal WebSocket server.
* Unauthenticated clients never reach the `connection` event: they are upgraded
* only to receive a close frame with a machine-readable code, then dropped.
*/
export function createTerminalUpgradeHandler({ wss, authenticate, onRejected = () => {}, terminateAfterMs = TERMINAL_REJECTED_SOCKET_TERMINATE_MS }) {
return (req, socket, head) => {
if (!wss.shouldHandle(req)) {
// Let ws answer unknown paths exactly as it did with the `server` option.
wss.handleUpgrade(req, socket, head, (ws) => ws.terminate());
return;
}
// The client may disconnect while Coolify is checking the session.
const destroyOnError = () => socket.destroy();
socket.on('error', destroyOnError);
Promise.resolve()
.then(() => authenticate(req))
.catch((error) => ({
authenticated: false,
closeCode: TERMINAL_CLOSE_CODES.AUTH_UNAVAILABLE,
reason: 'Internal Server Error',
error,
}))
.then((result) => {
socket.removeListener('error', destroyOnError);
if (socket.destroyed) {
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
if (result?.authenticated === true) {
wss.emit('connection', ws, req);
return;
}
onRejected(result, req);
rejectTerminalSocket(ws, result.closeCode, result.reason, { terminateAfterMs });
});
});
};
}
const DEFAULT_TERMINAL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
// Variables passed to the SSH process besides PATH. `cloudflared access ssh` reads the
// Access service token from them in the ProxyCommand of Cloudflare Tunnel servers.
const ALLOWED_TERMINAL_ENV = ['TUNNEL_SERVICE_TOKEN_ID', 'TUNNEL_SERVICE_TOKEN_SECRET'];
export function getTerminalProcessEnv(environment = process.env) {
const env = {
PATH: environment.PATH || DEFAULT_TERMINAL_PATH,
};
for (const name of ALLOWED_TERMINAL_ENV) {
if (environment[name]) {
env[name] = environment[name];
}
}
return env;
}
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;
}
// A `docker attach` session shares the container's main process. On disconnect we
// must detach from it instead of killing it, otherwise we stop the running app.
export function isAttachCommand(commandString) {
return /(?:^|\s)docker\s+attach(?:\s|$)/.test(commandString || '');
}
function normalizeShellArgument(argument) {
if (!argument) {
return argument;
}
return argument
.replace(/'([^']*)'/g, '$1')
.replace(/"([^"]*)"/g, '$1');
}
export function extractSshArgs(commandString) {
const sshCommandMatch = commandString.match(/ssh (.+?) '[^']+' << /);
if (!sshCommandMatch) return [];
const argsString = sshCommandMatch[1];
let sshArgs = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
let i = 0;
while (i < argsString.length) {
const char = argsString[i];
if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true;
quoteChar = char;
current += char;
} else if (inQuotes && char === quoteChar) {
inQuotes = false;
current += char;
quoteChar = '';
} else if (!inQuotes && char === ' ') {
if (current.trim()) {
sshArgs.push(current.trim());
current = '';
}
} else {
current += char;
}
i++;
}
if (current.trim()) {
sshArgs.push(current.trim());
}
sshArgs = sshArgs.map((arg) => normalizeShellArgument(arg));
return sshArgs.map(arg => arg === 'RequestTTY=no' ? 'RequestTTY=yes' : arg);
}
export function extractHereDocContent(commandString) {
const delimiterMatch = commandString.match(/<< (\S+)/);
const delimiter = delimiterMatch ? delimiterMatch[1] : null;
const escapedDelimiter = delimiter?.slice(1).trim().replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
if (!escapedDelimiter) {
return '';
}
const hereDocRegex = new RegExp(`<< \\\\${escapedDelimiter}([\\s\\S\\.]*?)${escapedDelimiter}`);
const hereDocMatch = commandString.match(hereDocRegex);
return hereDocMatch ? hereDocMatch[1] : '';
}
export function normalizeHostForAuthorization(host) {
if (!host) {
return null;
}
let normalizedHost = host.trim();
while (
normalizedHost.length >= 2 &&
((normalizedHost.startsWith("'") && normalizedHost.endsWith("'")) ||
(normalizedHost.startsWith('"') && normalizedHost.endsWith('"')))
) {
normalizedHost = normalizedHost.slice(1, -1).trim();
}
if (normalizedHost.startsWith('[') && normalizedHost.endsWith(']')) {
normalizedHost = normalizedHost.slice(1, -1);
}
return normalizedHost.toLowerCase();
}
export function extractTargetHost(sshArgs) {
const userAtHost = sshArgs.find(arg => {
if (arg.includes('storage/app/ssh/keys/')) {
return false;
}
return /^[^@]+@[^@]+$/.test(arg);
});
if (!userAtHost) {
return null;
}
const atIndex = userAtHost.indexOf('@');
return normalizeHostForAuthorization(userAtHost.slice(atIndex + 1));
}
export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) {
const normalizedTargetHost = normalizeHostForAuthorization(targetHost);
if (!normalizedTargetHost) {
return false;
}
return authorizedHosts
.map(host => normalizeHostForAuthorization(host))
.includes(normalizedTargetHost);
}
const REQUIRED_SSH_OPTIONS = new Set([
'StrictHostKeyChecking',
'UserKnownHostsFile',
'PasswordAuthentication',
'IdentitiesOnly',
'ClearAllForwardings',
'ForwardAgent',
'ConnectTimeout',
'ServerAliveInterval',
'RequestTTY',
'LogLevel',
]);
function isAllowedSshOption(name, value) {
const fixedOptions = {
PasswordAuthentication: 'no',
IdentitiesOnly: 'yes',
ClearAllForwardings: 'yes',
ForwardAgent: 'no',
LogLevel: 'ERROR',
ControlMaster: 'auto',
ProxyCommand: 'cloudflared access ssh --hostname %h',
};
if (Object.hasOwn(fixedOptions, name)) {
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';
}
if (name === 'ConnectTimeout' || name === 'ServerAliveInterval' || name === 'ControlPersist') {
return /^\d+$/.test(value) && Number(value) > 0;
}
if (name === 'ControlPath') {
return /^\/var\/www\/html\/storage\/app\/ssh\/mux\/mux_[a-zA-Z0-9_-]+$/.test(value);
}
return false;
}
export function validateSshArgs(sshArgs, authorizedHosts = []) {
if (!Array.isArray(sshArgs) || sshArgs.length === 0) {
return false;
}
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++) {
const argument = sshArgs[index];
if (typeof argument !== 'string' || /[\0\r\n]/.test(argument)) {
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 ?? '')) {
return false;
}
hasIdentityFile = true;
continue;
}
if (argument === '-p') {
const port = sshArgs[++index];
if (hasPort || !/^\d+$/.test(port ?? '') || Number(port) < 1 || Number(port) > 65535) {
return false;
}
hasPort = true;
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;
if (separator < 1) {
return false;
}
const name = option.slice(0, separator);
const value = option.slice(separator + 1);
if (seenOptions.has(name) || !isAllowedSshOption(name, value)) {
return false;
}
seenOptions.add(name);
continue;
}
return false;
}
const hasRequiredOptions = [...REQUIRED_SSH_OPTIONS].every(option => seenOptions.has(option));
const hasCompleteMultiplexingOptions =
!['ControlMaster', 'ControlPath', 'ControlPersist'].some(option => seenOptions.has(option))
|| ['ControlMaster', 'ControlPath', 'ControlPersist'].every(option => seenOptions.has(option));
return hasIdentityFile
&& hasPort
&& hasConfigFile
&& targetHost !== null
&& hasRequiredOptions
&& hasCompleteMultiplexingOptions
&& isAuthorizedTargetHost(targetHost, authorizedHosts);
}
export function sanitizeSshArgs(sshArgs) {
const multiplexingOptions = new Set(['ControlMaster', 'ControlPath', 'ControlPersist']);
const sanitizedArgs = [];
for (let index = 0; index < sshArgs.length; index++) {
if (sshArgs[index] === '-o') {
const optionName = sshArgs[index + 1]?.split('=', 1)[0];
if (multiplexingOptions.has(optionName)) {
index++;
continue;
}
}
sanitizedArgs.push(sshArgs[index]);
}
return sanitizedArgs;
}
// Wait for this PTY to exit, not for mutable session state to change.
export function detachPtyProcess(session) {
const ptyProcess = session.ptyProcess;
if (!ptyProcess || !session.isActive) return Promise.resolve(true);
return new Promise((resolve) => {
let timer;
let attempts = 0;
let settled = false;
const finish = (exited) => {
if (settled) return;
settled = true;
clearTimeout(timer);
exitListener.dispose();
if (exited && session.ptyProcess === ptyProcess) {
session.isActive = false;
session.ptyProcess = null;
clearTimeout(session.terminalSessionTimer);
session.terminalSessionTimer = null;
}
resolve(exited);
};
const exitListener = ptyProcess.onExit(() => finish(true));
const attemptDetach = () => {
attempts++;
try {
ptyProcess.write('\x10\x11');
} catch (_) {
// An exit event is still required to confirm teardown.
}
if (settled) return;
timer = setTimeout(() => {
if (attempts < 3) {
attemptDetach();
return;
}
// --sig-proxy=false prevents forwarding signals to the container.
// Refuse a replacement if killing the local SSH client does not exit.
timer = setTimeout(() => finish(false), 1000);
try {
ptyProcess.kill();
} catch (_) {
// The bounded exit wait fails closed.
}
}, 500);
};
attemptDetach();
});
}