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); });