coolify/docker/coolify-realtime/terminal-utils.js

271 lines
7.8 KiB
JavaScript

export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60;
const DEFAULT_TERMINAL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
export function getTerminalProcessEnv(environment = process.env) {
return {
PATH: environment.PATH || DEFAULT_TERMINAL_PATH,
};
}
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;
}
function normalizeShellArgument(argument) {
if (!argument) {
return argument;
}
return argument
.replace(/'([^']*)'/g, '$1')
.replace(/"([^"]*)"/g, '$1');
}
export function extractSshArgs(commandString) {
const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/);
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));
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;
}
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',
'ConnectTimeout',
'ServerAliveInterval',
'RequestTTY',
'LogLevel',
]);
function isAllowedSshOption(name, value) {
const fixedOptions = {
StrictHostKeyChecking: 'no',
UserKnownHostsFile: '/dev/null',
PasswordAuthentication: 'no',
LogLevel: 'ERROR',
ControlMaster: 'auto',
ProxyCommand: 'cloudflared access ssh --hostname %h',
};
if (Object.hasOwn(fixedOptions, name)) {
return value === fixedOptions[name];
}
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 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;
}
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 === '-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;
}
if (/^[a-zA-Z0-9_][a-zA-Z0-9._-]*@[^@]+$/.test(argument) && targetHost === null) {
targetHost = extractTargetHost([argument]);
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
&& 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;
}