158 lines
7.2 KiB
JavaScript
158 lines
7.2 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
MAX_TERMINAL_SESSION_TIMEOUT_SECONDS,
|
|
extractSshArgs,
|
|
extractTargetHost,
|
|
getTerminalProcessEnv,
|
|
getTerminalSessionTimeout,
|
|
isAuthorizedTargetHost,
|
|
normalizeHostForAuthorization,
|
|
sanitizeSshArgs,
|
|
validateSshArgs,
|
|
} from './terminal-utils.js';
|
|
|
|
test('getTerminalProcessEnv preserves the PATH needed by SSH proxy commands', () => {
|
|
assert.deepEqual(getTerminalProcessEnv({
|
|
PATH: '/usr/local/bin:/usr/bin:/bin',
|
|
APP_KEY: 'must-not-be-inherited',
|
|
}), {
|
|
PATH: '/usr/local/bin:/usr/bin:/bin',
|
|
});
|
|
});
|
|
|
|
test('getTerminalProcessEnv uses the default PATH when PATH is absent', () => {
|
|
assert.deepEqual(getTerminalProcessEnv({
|
|
APP_KEY: 'must-not-be-inherited',
|
|
}), {
|
|
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
|
});
|
|
});
|
|
|
|
test('getTerminalProcessEnv uses the default PATH when PATH is empty', () => {
|
|
assert.deepEqual(getTerminalProcessEnv({
|
|
PATH: '',
|
|
APP_KEY: 'must-not-be-inherited',
|
|
}), {
|
|
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
|
});
|
|
});
|
|
|
|
test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.equal(extractTargetHost(sshArgs), '10.0.0.5');
|
|
});
|
|
|
|
test('extractSshArgs strips shell quotes from port and user host arguments before spawning ssh', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 3600 ssh -p '22' -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.deepEqual(sshArgs.slice(0, 5), ['-p', '22', '-o', 'StrictHostKeyChecking=no', 'root@10.0.0.5']);
|
|
});
|
|
|
|
test('extractSshArgs preserves proxy command as a single normalized ssh option value', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -o StrictHostKeyChecking=no 'root'@'example.com' 'bash -se' << \\\\$abc\necho hi\nabc"
|
|
);
|
|
|
|
assert.equal(sshArgs[1], 'ProxyCommand=cloudflared access ssh --hostname %h');
|
|
assert.equal(sshArgs[4], 'root@example.com');
|
|
});
|
|
|
|
test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => {
|
|
assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true);
|
|
assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true);
|
|
});
|
|
|
|
test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
|
|
assert.equal(normalizeHostForAuthorization("'[2001:db8::10]'"), '2001:db8::10');
|
|
assert.equal(isAuthorizedTargetHost("'[2001:db8::10]'", ['2001:db8::10']), true);
|
|
});
|
|
|
|
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
|
|
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
|
|
});
|
|
|
|
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"
|
|
);
|
|
|
|
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true);
|
|
});
|
|
|
|
test('validateSshArgs rejects an injected ProxyCommand', () => {
|
|
const sshArgs = extractSshArgs(
|
|
"timeout 300 ssh -o 'ProxyCommand=/bin/busybox id >/tmp/marker' root@10.0.0.5 'bash -se' << \\ENDSSH\nENDSSH"
|
|
);
|
|
|
|
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false);
|
|
});
|
|
|
|
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"
|
|
);
|
|
const maliciousArgs = [...validArgs];
|
|
maliciousArgs[1] = 'ProxyCommand=cloudflared access ssh --hostname %h; id';
|
|
|
|
assert.equal(validateSshArgs(validArgs, ['example.com']), true);
|
|
assert.equal(validateSshArgs(maliciousArgs, ['example.com']), false);
|
|
});
|
|
|
|
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"
|
|
);
|
|
|
|
assert.equal(validateSshArgs(['-F', '/tmp/config', ...baseArgs], ['10.0.0.5']), false);
|
|
assert.equal(validateSshArgs(['-i', '/tmp/attacker-key', ...baseArgs.slice(2)], ['10.0.0.5']), false);
|
|
});
|
|
|
|
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"
|
|
);
|
|
|
|
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false);
|
|
});
|
|
|
|
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"
|
|
);
|
|
|
|
assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true);
|
|
assert.deepEqual(sanitizeSshArgs(sshArgs), [
|
|
'-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=yes',
|
|
'-o',
|
|
'LogLevel=ERROR',
|
|
'-p',
|
|
'22',
|
|
'root@10.0.0.5',
|
|
]);
|
|
});
|
|
|
|
|
|
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);
|
|
});
|