From 069db334312afa521d3349381070c30e41ecc9ac Mon Sep 17 00:00:00 2001 From: Shaidyk Date: Mon, 29 Jun 2026 11:28:15 +0300 Subject: [PATCH] fix: resolve #1 - Rewrite Coolify Core in Rust for Performance & Safety --- docker/coolify-realtime/terminal-utils.js | 50 +++++++++++++++---- .../coolify-realtime/terminal-utils.test.js | 36 +++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 7456b282c2..2afad8a738 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -97,21 +97,53 @@ export function normalizeHostForAuthorization(host) { return normalizedHost.toLowerCase(); } +// ssh(1) single-letter options that consume the following argument as their value. +// The value of such an option (e.g. `-o ProxyJump=user@jump`, `-i key@path`) must never +// be mistaken for the connection destination when authorizing a target host. +const SSH_VALUE_OPTION_FLAGS = new Set([ + '-B', '-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l', + '-m', '-O', '-o', '-p', '-Q', '-R', '-S', '-W', '-w', +]); + export function extractTargetHost(sshArgs) { - const userAtHost = sshArgs.find(arg => { - if (arg.includes('storage/app/ssh/keys/')) { - return false; + if (!Array.isArray(sshArgs)) { + throw new TypeError('extractTargetHost expects an array of ssh arguments'); + } + + // The real ssh destination is the first positional `user@host` argument: not an + // option flag, not the value consumed by a preceding value-taking flag, and not an + // option assignment (`key=value`) or identity-file path. Scanning for the first + // `user@host` token anywhere is unsafe — a crafted command can smuggle an authorized + // host into an option value (`-o ProxyJump=user@authorized`) while connecting elsewhere. + let skipNextValue = false; + + for (const arg of sshArgs) { + if (skipNextValue) { + skipNextValue = false; + continue; } - return /^[^@]+@[^@]+$/.test(arg); - }); + if (typeof arg !== 'string' || arg.length === 0) { + continue; + } - if (!userAtHost) { - return null; + if (arg.startsWith('-')) { + if (SSH_VALUE_OPTION_FLAGS.has(arg)) { + skipNextValue = true; + } + continue; + } + + if (arg.includes('=') || arg.includes('storage/app/ssh/keys/')) { + continue; + } + + if (/^[^@]+@[^@]+$/.test(arg)) { + return normalizeHostForAuthorization(arg.slice(arg.indexOf('@') + 1)); + } } - const atIndex = userAtHost.indexOf('@'); - return normalizeHostForAuthorization(userAtHost.slice(atIndex + 1)); + return null; } export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) { diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index 3da4441557..c2ca527c54 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -45,3 +45,39 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => { test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => { assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false); }); + +test('extractTargetHost ignores an authorized host smuggled into a ProxyJump option value', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o ProxyJump='root@10.0.0.5' -o StrictHostKeyChecking=no 'root'@'192.168.0.99' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + // The real connection target is the positional host, not the option value. + assert.equal(extractTargetHost(sshArgs), '192.168.0.99'); + // Authorizing the smuggled ProxyJump host would bypass the allowlist. + assert.equal(isAuthorizedTargetHost(extractTargetHost(sshArgs), ['10.0.0.5']), false); +}); + +test('extractTargetHost ignores ProxyJump passed via the -J value flag', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -J 'root@10.0.0.5' -o StrictHostKeyChecking=no 'root'@'192.168.0.99' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(extractTargetHost(sshArgs), '192.168.0.99'); +}); + +test('extractTargetHost still resolves the destination after a port and identity file', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -p '22' -i storage/app/ssh/keys/id@host -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(extractTargetHost(sshArgs), '10.0.0.5'); +}); + +test('extractTargetHost returns null when no positional host is present', () => { + assert.equal(extractTargetHost(['-o', 'StrictHostKeyChecking=no']), null); + assert.equal(extractTargetHost([]), null); +}); + +test('extractTargetHost throws a TypeError on non-array input', () => { + assert.throws(() => extractTargetHost('root@10.0.0.5'), TypeError); +});