271 lines
7.8 KiB
JavaScript
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;
|
|
}
|